본문으로 건너뛰기

모던 Redux로 마이그레이션하기

비공식 베타 번역

이 페이지는 PageTurner AI로 번역되었습니다(베타). 프로젝트 공식 승인을 받지 않았습니다. 오류를 발견하셨나요? 문제 신고 →

학습 내용
  • 레거시 "수동 작성" Redux 로직을 Redux Toolkit 사용으로 현대화하는 방법
  • 레거시 React-Redux connect 컴포넌트를 훅 API 사용으로 현대화하는 방법
  • TypeScript를 사용하는 Redux 로직과 React-Redux 컴포넌트 현대화 방법

개요

Redux는 2015년부터 존재해왔으며, Redux 코드 작성에 대한 권장 패턴은 수년간 크게 변화했습니다. React가 createClass에서 React.Component를 거쳐 훅이 있는 함수 컴포넌트로 진화한 것처럼, Redux도 수동 스토어 설정 + 객체 스프레드를 사용한 수동 리듀서 + React-Redux의 connect에서 Redux Toolkit의 configureStore + createSlice + React-Redux 훅 API로 진화했습니다.

많은 사용자들은 이러한 "모던 Redux" 패턴이 존재하기 이전부터 있던 오래된 Redux 코드베이스에서 작업하고 있습니다. 이러한 코드베이스를 오늘날 권장되는 모던 Redux 패턴으로 마이그레이션하면 훨씬 더 작고 유지보수하기 쉬운 코드베이스를 얻게 됩니다.

좋은 소식은 코드를 모던 Redux로 점진적으로, 조각조각 마이그레이션할 수 있으며, 기존과 새로운 Redux 코드가 공존하며 함께 작동한다는 점입니다!

이 페이지에서는 기존 레거시 Redux 코드베이스를 현대화하는 데 사용할 수 있는 일반적인 접근법과 기법을 다룹니다.

정보

Redux Toolkit + React-Redux 훅을 사용한 "모던 Redux"가 Redux 사용을 어떻게 단순화하는지 더 자세히 알아보려면 다음 추가 자료를 참조하세요:

Redux Toolkit을 사용한 Redux 로직 현대화

Redux 로직 마이그레이션의 일반적인 접근법은 다음과 같습니다:

  • 기존 수동 Redux 스토어 설정을 Redux Toolkit의 configureStore로 교체

  • 기존 슬라이스 리듀서와 관련 액션을 선택하여 RTK의 createSlice로 교체. 한 번에 하나의 리듀서씩 반복

  • 필요에 따라 기존 데이터 가져오기 로직을 RTK Query 또는 createAsyncThunk로 교체

  • 필요에 따라 createListenerMiddleware 또는 createEntityAdapter 같은 RTK의 다른 API 사용

항상 레거시 createStore 호출을 configureStore로 교체하는 것부터 시작해야 합니다. 이는 한 번만 수행하면 되며, 기존의 모든 리듀서와 미들웨어는 그대로 계속 작동합니다. configureStore는 실수로 인한 변이(mutation) 및 직렬화 불가능한 값 같은 일반적인 실수를 개발 모드에서 검사하므로, 이러한 검사 기능을 통해 코드베이스 내에서 해당 실수가 발생하는 영역을 식별하는 데 도움이 됩니다.

정보

이 일반적인 접근법의 실제 예시는 Redux Fundamentals, Part 8: Modern Redux with Redux Toolkit에서 확인할 수 있습니다.

configureStore를 사용한 스토어 설정

일반적인 레거시 Redux 스토어 설정 파일은 여러 단계를 수행합니다:

  • 슬라이스 리듀서를 루트 리듀서로 결합

  • 일반적으로 thunk 미들웨어와 함께 미들웨어 인핸서 생성 및 개발 모드에서 redux-logger 같은 다른 미들웨어 추가

  • Redux DevTools 인핸서 추가 및 인핸서 구성

  • createStore 호출

기존 애플리케이션에서 이러한 단계는 다음과 같이 보일 수 있습니다:

src/app/store.js
import { createStore, applyMiddleware, combineReducers, compose } from 'redux'
import { thunk } from 'redux-thunk'

import postsReducer from '../reducers/postsReducer'
import usersReducer from '../reducers/usersReducer'

const rootReducer = combineReducers({
posts: postsReducer,
users: usersReducer
})

const middlewareEnhancer = applyMiddleware(thunk)

const composeWithDevTools =
window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose

const composedEnhancers = composeWithDevTools(middlewareEnhancer)

const store = createStore(rootReducer, composedEnhancers)

이 모든 단계는 Redux Toolkit의 configureStore API를 한 번 호출하는 것으로 대체할 수 있습니다.

RTK의 configureStore는 기존 createStore 메서드를 감싸며 대부분의 스토어 설정을 자동으로 처리합니다. 실제로 이를 단일 단계로 효과적으로 축약할 수 있습니다:

Basic Store Setup: src/app/store.js
import { configureStore } from '@reduxjs/toolkit'

import postsReducer from '../reducers/postsReducer'
import usersReducer from '../reducers/usersReducer'

// Automatically adds the thunk middleware and the Redux DevTools extension
const store = configureStore({
// Automatically calls `combineReducers`
reducer: {
posts: postsReducer,
users: usersReducer
}
})

이 한 번의 configureStore 호출로 모든 작업이 처리됩니다:

  • postsReducerusersReducer를 결합해 루트 리듀서 함수를 생성하기 위해 combineReducers를 호출하며, 이는 {posts, users} 형태의 루트 상태를 처리합니다

  • 해당 루트 리듀서를 사용해 Redux 스토어를 생성하기 위해 createStore를 호출합니다

  • thunk 미들웨어를 자동으로 추가하고 applyMiddleware를 호출합니다

  • 실수로 상태를 변이시키는 것과 같은 일반적인 오류를 검사하는 추가 미들웨어 자동 추가

  • Redux 개발자 도구 확장 연결 자동 설정

추가 미들웨어 적용, thunk 미들웨어에 extra 인수 전달, 지속성(persisted) 루트 리듀서 생성 등 추가 단계가 필요한 경우에도 처리할 수 있습니다. 다음은 내장 미들웨어 커스터마이징과 Redux-Persist 활성화를 보여주는 상세 예시로, configureStore 작업 시 사용 가능한 옵션들을 보여줍니다:

Detailed Example: Custom Store Setup with Persistence and Middleware

This example shows several possible common tasks when setting up a Redux store:

  • Combining the reducers separately (sometimes needed due to other architectural constraints)
  • Adding additional middleware, both conditionally and unconditionally
  • Passing an "extra argument" into the thunk middleware, such as an API service layer
  • Using the Redux-Persist library, which requires special handling for its non-serializable action types
  • Turning the devtools off in prod, and setting additional devtools options in development

None of these are required, but they do show up frequently in real-world codebases.

Custom Store Setup: src/app/store.js
import { configureStore, combineReducers } from '@reduxjs/toolkit'
import {
persistStore,
persistReducer,
FLUSH,
REHYDRATE,
PAUSE,
PERSIST,
PURGE,
REGISTER
} from 'redux-persist'
import storage from 'redux-persist/lib/storage'
import { PersistGate } from 'redux-persist/integration/react'
import logger from 'redux-logger'

import postsReducer from '../features/posts/postsSlice'
import usersReducer from '../features/users/usersSlice'
import { api } from '../features/api/apiSlice'
import { serviceLayer } from '../features/api/serviceLayer'

import stateSanitizerForDevtools from './devtools'
import customMiddleware from './someCustomMiddleware'

// Can call `combineReducers` yourself if needed
const rootReducer = combineReducers({
posts: postsReducer,
users: usersReducer,
[api.reducerPath]: api.reducer
})

const persistConfig = {
key: 'root',
version: 1,
storage
}

const persistedReducer = persistReducer(persistConfig, rootReducer)

const store = configureStore({
// Pass previously created persisted reducer
reducer: persistedReducer,
middleware: getDefaultMiddleware => {
const middleware = getDefaultMiddleware({
// Pass in a custom `extra` argument to the thunk middleware
thunk: {
extraArgument: { serviceLayer }
},
// Customize the built-in serializability dev check
serializableCheck: {
ignoredActions: [FLUSH, REHYDRATE, PAUSE, PERSIST, PURGE, REGISTER]
}
}).concat(customMiddleware, api.middleware)

// Conditionally add another middleware in dev
if (process.env.NODE_ENV !== 'production') {
middleware.push(logger)
}

return middleware
},
// Turn off devtools in prod, or pass options in dev
devTools:
process.env.NODE_ENV === 'production'
? false
: {
stateSanitizer: stateSanitizerForDevtools
}
})

createSlice를 사용한 리듀서와 액션

전형적인 레거시 Redux 코드베이스에서는 리듀서 로직, 액션 생성자, 액션 타입이 별도 파일에 분산되어 있으며, 종종 유형별로 다른 폴더에 위치합니다. 리듀서 로직은 switch 문과 객체 스프레드/배열 매핑을 이용한 수동 불변 업데이트로 작성됩니다:

src/constants/todos.js
export const ADD_TODO = 'ADD_TODO'
export const TOGGLE_TODO = 'TOGGLE_TODO'
src/actions/todos.js
import { ADD_TODO, TOGGLE_TODO } from '../constants/todos'

export const addTodo = (id, text) => ({
type: ADD_TODO,
text,
id
})

export const toggleTodo = id => ({
type: TOGGLE_TODO,
id
})
src/reducers/todos.js
import { ADD_TODO, TOGGLE_TODO } from '../constants/todos'

const initialState = []

export default function todosReducer(state = initialState, action) {
switch (action.type) {
case ADD_TODO: {
return state.concat({
id: action.id,
text: action.text,
completed: false
})
}
case TOGGLE_TODO: {
return state.map(todo => {
if (todo.id !== action.id) {
return todo
}

return {
...todo,
completed: !todo.completed
}
})
}
default:
return state
}
}

Redux Toolkit의 createSlice API는 리듀서/액션 작성과 불변 업데이트 시 발생하는 모든 "보일러플레이트"를 제거하기 위해 설계되었습니다!

Redux Toolkit을 사용하면 레거시 코드에 다음과 같은 다중 변경 사항이 적용됩니다:

  • createSlice가 수동 작성된 액션 생성자와 액션 타입을 완전히 제거합니다

  • action.text, action.id 같은 고유 이름 필드들이 개별 값 또는 해당 필드들을 포함한 객체 형태의 action.payload로 대체됩니다

  • Immer 덕분에 수동 불변 업데이트가 리듀서 내 "변형(mutating)" 로직으로 대체됩니다

  • 코드 유형별 별도 파일이 필요 없어집니다

  • 특정 리듀서 관련 모든 로직을 단일 "슬라이스" 파일에 포함하는 방식을 권장합니다

  • "코드 유형"별 분리된 폴더 구조 대신 "기능" 중심으로 파일을 구성하고 관련 코드를 동일 폴더에 배치하는 것을 권장합니다

  • ADD_TODO 같은 명령형 표현 대신 todoAdded처럼 과거 시제로 "발생한 사건"을 설명하는 리듀서/액션 명명 방식을 이상적으로 사용해야 합니다

상수/액션/리듀서용 별도 파일들은 모두 단일 "슬라이스" 파일로 대체됩니다. 현대화된 슬라이스 파일은 다음과 같습니다:

src/features/todos/todosSlice.js
import { createSlice } from '@reduxjs/toolkit'

const initialState = []

const todosSlice = createSlice({
name: 'todos',
initialState,
reducers: {
// Give case reducers meaningful past-tense "event"-style names
todoAdded(state, action) {
const { id, text } = action.payload
// "Mutating" update syntax thanks to Immer, and no `return` needed
state.todos.push({
id,
text,
completed: false
})
},
todoToggled(state, action) {
// Look for the specific nested object to update.
// In this case, `action.payload` is the default field in the action,
// and can hold the `id` value - no need for `action.id` separately
const matchingTodo = state.todos.find(todo => todo.id === action.payload)

if (matchingTodo) {
// Can directly "mutate" the nested object
matchingTodo.completed = !matchingTodo.completed
}
}
}
})

// `createSlice` automatically generated action creators with these names.
// export them as named exports from this "slice" file
export const { todoAdded, todoToggled } = todosSlice.actions

// Export the slice reducer as the default export
export default todosSlice.reducer

dispatch(todoAdded('Buy milk')) 호출 시 todoAdded 액션 생성자에 전달한 단일 값이 자동으로 action.payload 필드로 사용됩니다. 다중 값 전달 시 dispatch(todoAdded({id, text}))처럼 객체로 전달하세요. 또는 createSlice 리듀서 내 "prepare" 표기법을 사용해 다중 개별 인수를 수용하고 payload 필드를 생성할 수 있습니다. prepare 표기법은 항목별 고유 ID 생성처럼 액션 생성자가 추가 작업을 수행하던 경우에도 유용합니다.

Redux Toolkit은 폴더 구조/파일 구성/액션 명명에 특별히 제약을 두지 않지만, 보다 유지보수성과 가독성이 높은 코드를 위해 이러한 모범 사례를 권장합니다.

RTK Query를 이용한 데이터 가져오기

React+Redux 앱에서의 전형적인 레거시 데이터 가져오기에는 다음과 같은 다중 구성 요소와 코드 유형이 필요합니다:

  • "요청 시작", "요청 성공", "요청 실패"를 표현하는 액션 생성자 및 액션 타입

  • 액션 디스패치와 비동기 요청 수행을 위한 thunks

  • 로딩 상태 추적 및 캐시 데이터 저장을 위한 리듀서

  • 스토어에서 해당 값을 읽기 위한 셀렉터

  • 클래스 컴포넌트에서는 componentDidMount를, 함수 컴포넌트에서는 useEffect를 통해 마운트 후 컴포넌트 내에서 thunk를 디스패치하는 것

이들은 일반적으로 여러 다른 파일에 분산되어 있습니다:

src/constants/todos.js
export const FETCH_TODOS_STARTED = 'FETCH_TODOS_STARTED'
export const FETCH_TODOS_SUCCEEDED = 'FETCH_TODOS_SUCCEEDED'
export const FETCH_TODOS_FAILED = 'FETCH_TODOS_FAILED'
src/actions/todos.js
import axios from 'axios'
import {
FETCH_TODOS_STARTED,
FETCH_TODOS_SUCCEEDED,
FETCH_TODOS_FAILED
} from '../constants/todos'

export const fetchTodosStarted = () => ({
type: FETCH_TODOS_STARTED
})

export const fetchTodosSucceeded = todos => ({
type: FETCH_TODOS_SUCCEEDED,
todos
})

export const fetchTodosFailed = error => ({
type: FETCH_TODOS_FAILED,
error
})

export const fetchTodos = () => {
return async dispatch => {
dispatch(fetchTodosStarted())

try {
// Axios is common, but also `fetch`, or your own "API service" layer
const res = await axios.get('/todos')
dispatch(fetchTodosSucceeded(res.data))
} catch (err) {
dispatch(fetchTodosFailed(err))
}
}
}
src/reducers/todos.js
import {
FETCH_TODOS_STARTED,
FETCH_TODOS_SUCCEEDED,
FETCH_TODOS_FAILED
} from '../constants/todos'

const initialState = {
status: 'uninitialized',
todos: [],
error: null
}

export default function todosReducer(state = initialState, action) {
switch (action.type) {
case FETCH_TODOS_STARTED: {
return {
...state,
status: 'loading'
}
}
case FETCH_TODOS_SUCCEEDED: {
return {
...state,
status: 'succeeded',
todos: action.todos
}
}
case FETCH_TODOS_FAILED: {
return {
...state,
status: 'failed',
todos: [],
error: action.error
}
}
default:
return state
}
}
src/selectors/todos.js
export const selectTodosStatus = state => state.todos.status
export const selectTodos = state => state.todos.todos
src/components/TodosList.js
import { useEffect } from 'react'
import { useSelector, useDispatch } from 'react-redux'
import { fetchTodos } from '../actions/todos'
import { selectTodosStatus, selectTodos } from '../selectors/todos'

export function TodosList() {
const dispatch = useDispatch()
const status = useSelector(selectTodosStatus)
const todos = useSelector(selectTodos)

useEffect(() => {
dispatch(fetchTodos())
}, [dispatch])

// omit rendering logic here
}

많은 사용자가 데이터 패칭을 관리하기 위해 redux-saga 라이브러리를 사용할 수 있으며, 이 경우 사가를 트리거하는 데 사용되는 추가적인 "시그널" 액션 타입이 있을 수 있고, thunk 대신 다음과 같은 사가 파일이 있을 수 있습니다:

src/sagas/todos.js
import { put, takeEvery, call } from 'redux-saga/effects'
import {
FETCH_TODOS_BEGIN,
fetchTodosStarted,
fetchTodosSucceeded,
fetchTodosFailed
} from '../actions/todos'

// Saga to actually fetch data
export function* fetchTodos() {
yield put(fetchTodosStarted())

try {
const res = yield call(axios.get, '/todos')
yield put(fetchTodosSucceeded(res.data))
} catch (err) {
yield put(fetchTodosFailed(err))
}
}

// "Watcher" saga that waits for a "signal" action, which is
// dispatched only to kick off logic, not to update state
export function* fetchTodosSaga() {
yield takeEvery(FETCH_TODOS_BEGIN, fetchTodos)
}

이 코드 전부를 Redux Toolkit의 "RTK Query" 데이터 패칭 및 캐싱 레이어로 대체할 수 있습니다!

RTK Query는 데이터 패칭을 관리하기 위해 어떠한 액션, thunk, 리듀서, 셀렉터 또는 이펙트도 작성할 필요를 없앱니다. (사실 내부적으로는 동일한 도구들을 _사용_합니다.) 또한 RTK Query는 로딩 상태 추적, 요청 중복 제거, 캐시 데이터 라이프사이클 관리(더 이상 필요하지 않은 만료된 데이터 제거 포함)를 처리합니다.

마이그레이션하려면, 단일 RTK Query "API slice" 정의를 설정하고 생성된 리듀서 + 미들웨어를 스토어에 추가하세요:

src/features/api/apiSlice.js
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'

export const api = createApi({
baseQuery: fetchBaseQuery({
// Fill in your own server starting URL here
baseUrl: '/'
}),
endpoints: build => ({})
})
src/app/store.js
import { configureStore } from '@reduxjs/toolkit'

// Import the API object
import { api } from '../features/api/apiSlice'
// Import any other slice reducers as usual here
import usersReducer from '../features/users/usersSlice'

export const store = configureStore({
reducer: {
// Add the generated RTK Query "API slice" caching reducer
[api.reducerPath]: api.reducer,
// Add any other reducers
users: usersReducer
},
// Add the RTK Query API middleware
middleware: getDefaultMiddleware =>
getDefaultMiddleware().concat(api.middleware)
})

그런 다음, 가져오고 캐시하려는 특정 데이터를 나타내는 "엔드포인트"를 추가하고 각 엔드포인트에 대해 자동 생성된 React 훅을 내보내세요:

src/features/api/apiSlice.js
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'

export const api = createApi({
baseQuery: fetchBaseQuery({
// Fill in your own server starting URL here
baseUrl: '/'
}),
endpoints: build => ({
// A query endpoint with no arguments
getTodos: build.query({
query: () => '/todos'
}),
// A query endpoint with an argument
userById: build.query({
query: userId => `/users/${userId}`
}),
// A mutation endpoint
updateTodo: build.mutation({
query: updatedTodo => ({
url: `/todos/${updatedTodo.id}`,
method: 'POST',
body: updatedTodo
})
})
})
})

export const { useGetTodosQuery, useUserByIdQuery, useUpdateTodoMutation } = api

마지막으로, 컴포넌트에서 훅을 사용하세요:

src/features/todos/TodoList.js
import { useGetTodosQuery } from '../api/apiSlice'

export function TodoList() {
const { data: todos, isFetching, isSuccess } = useGetTodosQuery()

// omit rendering logic here
}

createAsyncThunk를 사용한 데이터 패칭

우리는 데이터 패칭에는 특히 RTK Query 사용을 권장합니다. 그러나 일부 사용자는 아직 그 단계를 준비하지 못했다고 말합니다. 그런 경우에는 RTK의 createAsyncThunk를 사용하여 수동으로 작성하는 thunk와 리듀서의 상용구 코드를 줄일 수 있습니다. 이 함수는 액션 생성자와 액션 타입을 자동으로 생성하고, 요청을 수행하기 위해 제공한 비동기 함수를 호출하며, 프로미스 라이프사이클에 따라 해당 액션들을 디스패치합니다. createAsyncThunk를 사용한 동일한 예제는 다음과 같을 수 있습니다:

src/features/todos/todosSlice
import { createAsyncThunk, createSlice } from '@reduxjs/toolkit'
import axios from 'axios'

const initialState = {
status: 'uninitialized',
todos: [],
error: null
}

const fetchTodos = createAsyncThunk('todos/fetchTodos', async () => {
// Just make the async request here, and return the response.
// This will automatically dispatch a `pending` action first,
// and then `fulfilled` or `rejected` actions based on the promise.
// as needed based on the
const res = await axios.get('/todos')
return res.data
})

export const todosSlice = createSlice({
name: 'todos',
initialState,
reducers: {
// any additional "normal" case reducers here.
// these will generate new action creators
},
extraReducers: builder => {
// Use `extraReducers` to handle actions that were generated
// _outside_ of the slice, such as thunks or in other slices
builder
.addCase(fetchTodos.pending, (state, action) => {
state.status = 'loading'
})
// Pass the generated action creators to `.addCase()`
.addCase(fetchTodos.fulfilled, (state, action) => {
// Same "mutating" update syntax thanks to Immer
state.status = 'succeeded'
state.todos = action.payload
})
.addCase(fetchTodos.rejected, (state, action) => {
state.status = 'failed'
state.todos = []
state.error = action.error
})
}
})

export default todosSlice.reducer

또한 셀렉터를 작성해야 하며, useEffect 훅에서 fetchTodos thunk를 직접 디스패치해야 합니다.

createListenerMiddleware를 사용한 반응형 로직

많은 Redux 앱은 특정 액션이나 상태 변경을 감지하고 이에 대한 응답으로 추가 로직을 실행하는 "반응형" 스타일 로직을 가지고 있습니다. 이러한 동작은 주로 redux-saga 또는 redux-observable 라이브러리를 사용하여 구현됩니다.

이러한 라이브러리는 다양한 작업에 사용됩니다. 기본 예시로, 액션을 감지하고 1초를 기다린 후 추가 액션을 디스패치하는 사가와 에픽은 다음과 같을 수 있습니다:

src/sagas/ping.js
import { delay, put, takeEvery } from 'redux-saga/effects'

export function* ping() {
yield delay(1000)
yield put({ type: 'PONG' })
}

// "Watcher" saga that waits for a "signal" action, which is
// dispatched only to kick off logic, not to update state
export function* pingSaga() {
yield takeEvery('PING', ping)
}
src/epics/ping.js
import { filter, mapTo } from 'rxjs/operators'
import { ofType } from 'redux-observable'

const pingEpic = action$ =>
action$.pipe(ofType('PING'), delay(1000), mapTo({ type: 'PONG' }))
src/app/store.js
import { createStore, applyMiddleware } from 'redux'
import createSagaMiddleware from 'redux-saga'
import { combineEpics, createEpicMiddleware } from 'redux-observable';

// skip reducers

import { pingEpic } from '../sagas/ping'
import { pingSaga } from '../epics/ping

function* rootSaga() {
yield pingSaga()
}

const rootEpic = combineEpics(
pingEpic
);

const sagaMiddleware = createSagaMiddleware()
const epicMiddleware = createEpicMiddleware()

const middlewareEnhancer = applyMiddleware(sagaMiddleware, epicMiddleware)

const store = createStore(rootReducer, middlewareEnhancer)

sagaMiddleware.run(rootSaga)
epicMiddleware.run(rootEpic)

RTK "listener" 미들웨어는 사가와 옵저버블을 대체하기 위해 설계되었으며, 더 간단한 API, 더 작은 번들 크기, 더 나은 TS 지원을 제공합니다.

사가와 에픽 예제는 다음과 같이 리스너 미들웨어로 대체될 수 있습니다:

src/app/listenerMiddleware.js
import { createListenerMiddleware } from '@reduxjs/toolkit'

// Best to define this in a separate file, to avoid importing
// from the store file into the rest of the codebase
export const listenerMiddleware = createListenerMiddleware()

export const { startListening, stopListening } = listenerMiddleware
src/features/ping/pingSlice.js
import { createSlice } from '@reduxjs/toolkit'
import { startListening } from '../../app/listenerMiddleware'

const pingSlice = createSlice({
name: 'ping',
initialState,
reducers: {
pong(state, action) {
// state update here
}
}
})

export const { pong } = pingSlice.actions
export default pingSlice.reducer

// The `startListening()` call could go in different files,
// depending on your preferred app setup. Here, we just add
// it directly in a slice file.
startListening({
// Match this exact action type based on the action creator
actionCreator: pong,
// Run this effect callback whenever that action is dispatched
effect: async (action, listenerApi) => {
// Listener effect functions get a `listenerApi` object
// with many useful methods built in, including `delay`:
await listenerApi.delay(1000)
listenerApi.dispatch(pong())
}
})
src/app/store.js
import { configureStore } from '@reduxjs/toolkit'

import { listenerMiddleware } from './listenerMiddleware'

// omit reducers

export const store = configureStore({
reducer: rootReducer,
// Add the listener middleware _before_ the thunk or dev checks
middleware: getDefaultMiddleware =>
getDefaultMiddleware().prepend(listenerMiddleware.middleware)
})

Redux 로직을 위한 TypeScript 마이그레이션

TypeScript를 사용하는 레거시 Redux 코드는 일반적으로 타입 정의에 매우 장황한 패턴을 따릅니다. 특히, 커뮤니티의 많은 사용자들은 각 개별 액션에 대한 TS 타입을 수동으로 정의한 다음, dispatch에 실제로 전달할 수 있는 구체적인 액션을 제한하기 위한 "액션 타입 유니온"을 생성하기로 결정했습니다.

우리는 이러한 패턴을 _사용하지 않는 것_을 구체적이고 강력하게 권장합니다!

src/actions/todos.ts
import { ADD_TODO, TOGGLE_TODO } from '../constants/todos'

// ❌ Common pattern: manually defining types for each action object
interface AddTodoAction {
type: typeof ADD_TODO
text: string
id: string
}

interface ToggleTodoAction {
type: typeof TOGGLE_TODO
id: string
}

// ❌ Common pattern: an "action type union" of all possible actions
export type TodoActions = AddTodoAction | ToggleTodoAction

export const addTodo = (id: string, text: string): AddTodoAction => ({
type: ADD_TODO,
text,
id
})

export const toggleTodo = (id: string): ToggleTodoAction => ({
type: TOGGLE_TODO,
id
})
src/reducers/todos.ts
import { ADD_TODO, TOGGLE_TODO, TodoActions } from '../constants/todos'

interface Todo {
id: string
text: string
completed: boolean
}

export type TodosState = Todo[]

const initialState: TodosState = []

export default function todosReducer(
state = initialState,
action: TodoActions
) {
switch (action.type) {
// omit reducer logic
default:
return state
}
}
src/app/store.ts
import { createStore, Dispatch } from 'redux'

import { TodoActions } from '../actions/todos'
import { CounterActions } from '../actions/counter'
import { TodosState } from '../reducers/todos'
import { CounterState } from '../reducers/counter'

// omit reducer setup

export const store = createStore(rootReducer)

// ❌ Common pattern: an "action type union" of all possible actions
export type RootAction = TodoActions | CounterActions
// ❌ Common pattern: manually defining the root state type with each field
export interface RootState {
todos: TodosState
counter: CounterState
}

// ❌ Common pattern: limiting what can be dispatched at the types level
export type AppDispatch = Dispatch<RootAction>

Redux Toolkit은 TS 사용을 극적으로 단순화하도록 설계되었으며, 우리의 권장 사항은 가능한 한 타입을 _추론_하는 것을 포함합니다!

표준 TypeScript 설정 및 사용 가이드에 따라, 스토어 파일을 설정하여 AppDispatchRootState 타입을 스토어 자체에서 직접 추론하도록 시작하세요. 이렇게 하면 미들웨어에 의해 추가된 dispatch의 수정 사항(예: thunk 디스패치 기능)이 올바르게 포함되고, 슬라이스의 상태 정의를 수정하거나 더 많은 슬라이스를 추가할 때마다 RootState 타입이 업데이트됩니다.

app/store.ts
import { configureStore } from '@reduxjs/toolkit'
// omit any other imports

const store = configureStore({
reducer: {
todos: todosReducer,
counter: counterReducer
}
})

// Infer the `RootState` and `AppDispatch` types from the store itself

// Inferred state type: {todos: TodosState, counter: CounterState}
export type RootState = ReturnType<typeof store.getState>

// Inferred dispatch type: Dispatch & ThunkDispatch<RootState, undefined, UnknownAction>
export type AppDispatch = typeof store.dispatch

각 슬라이스 파일은 자체 슬라이스 상태에 대한 타입을 선언하고 내보내야 합니다. 그런 다음 createSlice.reducers 내부의 모든 action 인수 타입을 선언하기 위해 PayloadAction 타입을 사용하세요. 그러면 생성된 액션 생성자는 수락하는 인수와 반환하는 action.payload 타입 모두에 올바른 타입을 갖게 됩니다.

src/features/todos/todosSlice.ts
import { createSlice, PayloadAction } from '@reduxjs/toolkit'

interface Todo {
id: string
text: string
completed: boolean
}

// Declare and export a type for the slice's state
export type TodosState = Todo[]

const initialState: TodosState = []

const todosSlice = createSlice({
name: 'todos',
// The `state` argument type will be inferred for all case reducers
// from the type of `initialState`
initialState,
reducers: {
// Use `PayloadAction<YourPayloadTypeHere>` for each `action` argument
todoAdded(state, action: PayloadAction<{ id: string; text: string }>) {
// omit logic
},
todoToggled(state, action: PayloadAction<string>) {
// omit logic
}
}
})

React-Redux를 사용한 React 컴포넌트 현대화

컴포넌트에서 React-Redux 사용을 마이그레이션하기 위한 일반적인 접근 방식은 다음과 같습니다:

  • 기존 React 클래스 컴포넌트를 함수 컴포넌트로 마이그레이션합니다

  • connect 래퍼를 컴포넌트 내부에서 useSelectoruseDispatch 훅 사용으로 대체합니다

이 작업은 컴포넌트별로 개별적으로 수행할 수 있습니다. connect를 사용하는 컴포넌트와 훅을 사용하는 컴포넌트는 동시에 공존할 수 있습니다.

이 페이지에서는 클래스 컴포넌트를 함수 컴포넌트로 마이그레이션하는 과정을 다루지 않으며, React-Redux에 특화된 변경 사항에 초점을 맞출 것입니다.

connect를 훅으로 마이그레이션

React-Redux의 connect API를 사용하는 일반적인 레거시 컴포넌트는 다음과 같이 생겼을 수 있습니다:

src/features/todos/TodoListItem.js
import { connect } from 'react-redux'
import { bindActionCreators } from 'redux'
import {
todoToggled,
todoDeleted,
selectTodoById,
selectActiveTodoId
} from './todosSlice'

// A `mapState` function, possibly using values from `ownProps`,
// and returning an object with multiple separate fields inside
const mapStateToProps = (state, ownProps) => {
return {
todo: selectTodoById(state, ownProps.todoId),
activeTodoId: selectActiveTodoId(state)
}
}

// Several possible variations on how you might see `mapDispatch` written:

// 1) a separate function, manual wrapping of `dispatch`
const mapDispatchToProps = dispatch => {
return {
todoDeleted: id => dispatch(todoDeleted(id)),
todoToggled: id => dispatch(todoToggled(id))
}
}

// 2) A separate function, wrapping with `bindActionCreators`
const mapDispatchToProps2 = dispatch => {
return bindActionCreators(
{
todoDeleted,
todoToggled
},
dispatch
)
}

// 3) An object full of action creators
const mapDispatchToProps3 = {
todoDeleted,
todoToggled
}

// The component, which gets all these fields as props
function TodoListItem({ todo, activeTodoId, todoDeleted, todoToggled }) {
// rendering logic here
}

// Finished with the call to `connect`
export default connect(mapStateToProps, mapDispatchToProps)(TodoListItem)

React-Redux 훅 API를 사용하면 connect 호출과 mapState/mapDispatch 인수는 훅으로 대체됩니다!

  • mapState에서 반환된 각 개별 필드는 별도의 useSelector 호출이 됩니다

  • mapDispatch를 통해 전달된 각 함수는 컴포넌트 내부에 정의된 별도의 콜백 함수가 됩니다

src/features/todos/TodoListItem.js
import { useState } from 'react'
import { useSelector, useDispatch } from 'react-redux'
import {
todoAdded,
todoToggled,
selectTodoById,
selectActiveTodoId
} from './todosSlice'

export function TodoListItem({ todoId }) {
// Get the actual `dispatch` function with `useDispatch`
const dispatch = useDispatch()

// Select values from the state with `useSelector`
const activeTodoId = useSelector(selectActiveTodoId)
// Use prop in scope to select a specific value
const todo = useSelector(state => selectTodoById(state, todoId))

// Create callback functions that dispatch as needed, with arguments
const handleToggleClick = () => {
dispatch(todoToggled(todoId))
}

const handleDeleteClick = () => {
dispatch(todoDeleted(todoId))
}

// omit rendering logic
}

차이점 중 하나는 connect는 들어오는 stateProps+dispatchProps+ownProps가 변경되지 않는 한 래핑된 컴포넌트의 렌더링을 방지하여 렌더링 성능을 최적화했다는 점입니다. 훅은 컴포넌트 내부에 있기 때문에 이를 수행할 수 없습니다. React의 일반적인 재귀 렌더링 동작을 방지해야 하는 경우 컴포넌트를 React.memo(MyComponent)로 직접 래핑하세요.

컴포넌트용 TypeScript 마이그레이션

connect의 주요 단점 중 하나는 타입을 올바르게 지정하기가 매우 어렵고 타입 선언이 지나치게 장황해진다는 점입니다. 이는 고차 컴포넌트(Higher-Order Component)이며 API의 유연성(네 개의 선택적 인수, 각각 여러 오버로드 및 변형 가능) 때문입니다.

커뮤니티는 다양한 복잡성 수준으로 이를 처리하기 위한 여러 변형을 제시했습니다. 가장 간단한 경우 일부 사용법에서는 mapState()에서 state를 타이핑한 다음 컴포넌트의 모든 props 타입을 계산해야 했습니다:

Simple connect TS example
import { connect } from 'react-redux'
import { RootState } from '../../app/store'
import {
todoToggled,
todoDeleted,
selectTodoById,
selectActiveTodoId
} from './todosSlice'

interface TodoListItemOwnProps {
todoId: string
}

const mapStateToProps = (state: RootState, ownProps) => {
return {
todo: selectTodoById(state, ownProps.todoId),
activeTodoId: selectActiveTodoId(state)
}
}

const mapDispatchToProps = {
todoDeleted,
todoToggled
}

type TodoListItemProps = TodoListItemOwnProps &
ReturnType<typeof mapStateToProps> &
typeof mapDispatchToProps

function TodoListItem({
todo,
activeTodoId,
todoDeleted,
todoToggled
}: TodoListItemProps) {}

export default connect(mapStateToProps, mapDispatchToProps)(TodoListItem)

특히 typeof mapDispatch를 객체로 사용하는 것은 썽크(thunk)가 포함된 경우 실패하기 때문에 위험했습니다.

다른 커뮤니티 생성 패턴은 dispatch: Dispatch<RootActions> 타입을 전달하기 위해 mapDispatch를 함수로 선언하고 bindActionCreators를 호출하거나, 래핑된 컴포넌트가 수신하는 모든 props의 타입을 수동으로 계산하고 이를 connect에 제네릭으로 전달하는 등 상당한 오버헤드가 필요했습니다.

약간 더 나은 대안은 v7.x에서 @types/react-redux에 추가된 ConnectedProps<T> 타입으로, connect에서 컴포넌트에 전달될 모든 props 타입을 추론할 수 있게 합니다. 이는 추론이 올바르게 작동하도록 connect 호출을 두 부분으로 분할해야 했습니다:

ConnectedProps<T> TS example
import { connect, ConnectedProps } from 'react-redux'
import { RootState } from '../../app/store'
import {
todoToggled,
todoDeleted,
selectTodoById,
selectActiveTodoId
} from './todosSlice'

interface TodoListItemOwnProps {
todoId: string
}

const mapStateToProps = (state: RootState, ownProps) => {
return {
todo: selectTodoById(state, ownProps.todoId),
activeTodoId: selectActiveTodoId(state)
}
}

const mapDispatchToProps = {
todoDeleted,
todoToggled
}

// Call the first part of `connect` to get the function that accepts the component.
// This knows the types of the props returned by `mapState/mapDispatch`
const connector = connect(mapStateToProps, mapDispatchToProps)
// The `ConnectedProps<T> util type can extract "the type of all props from Redux"
type PropsFromRedux = ConnectedProps<typeof connector>

// The final component props are "the props from Redux" + "props from the parent"
type TodoListItemProps = PropsFromRedux & TodoListItemOwnProps

// That type can then be used in the component
function TodoListItem({
todo,
activeTodoId,
todoDeleted,
todoToggled
}: TodoListItemProps) {}

// And the final wrapped component is generated and exported
export default connector(TodoListItem)

React-Redux 훅 API는 TypeScript와 함께 사용하기 훨씬 더 간단합니다! 컴포넌트 래핑 계층, 타입 추론 및 제네릭을 다루는 대신 훅은 인수를 받아 결과를 반환하는 간단한 함수입니다. 전달해야 하는 것은 RootStateAppDispatch 타입뿐입니다.

표준 TypeScript 설정 및 사용 가이드에 따라, 우리는 특히 훅에 대해 "사전 타이핑된" 별칭을 설정하여 올바른 타입이 내장되도록 하고 애플리케이션에서 이러한 사전 타이핑된 훅만 사용하도록 가르칩니다.

먼저 훅을 설정합니다:

src/app/hooks.ts
import { useDispatch, useSelector } from 'react-redux'
import type { AppDispatch, RootState } from './store'

// Use throughout your app instead of plain `useDispatch` and `useSelector`
export const useAppDispatch = useDispatch.withTypes<AppDispatch>()
export const useAppSelector = useSelector.withTypes<RootState>()

그런 다음 컴포넌트에서 사용하세요:

src/features/todos/TodoListItem.tsx
import { useAppSelector, useAppDispatch } from '../../app/hooks'
import {
todoToggled,
todoDeleted,
selectTodoById,
selectActiveTodoId
} from './todosSlice'

interface TodoListItemProps {
todoId: string
}

function TodoListItem({ todoId }: TodoListItemProps) {
// Use the pre-typed hooks in the component
const dispatch = useAppDispatch()
const activeTodoId = useAppSelector(selectActiveTodoId)
const todo = useAppSelector(state => selectTodoById(state, todoId))

// omit event handlers and rendering logic
}

추가 정보

자세한 내용은 다음 문서 페이지와 블로그 포스트를 참조하세요: