본문 바로가기

FrontEnd

Redux Toolkit : Usage Guide(사용 가이드)

반응형
리덕스 툴킷 공식문서의 사용 가이드 항목을 읽어봅니다. 
https://redux-toolkit.js.org/usage/usage-guide
 

Usage Guide | Redux Toolkit

 

redux-toolkit.js.org

Quick Start 페이지에서 설명한 대로 Redux 툴킷의 목표는 Redux 사용을 단순화하는 것입니다.

Redux로 하고자 하는 모든 것에 대한 완전한 솔루션은 아니지만

Redux로 앱을 작성하는데 필요한 많은 코드를 자동으로 만들어줍니다.

 

Redux 툴킷은 애플리케이션에서 사용할 수 있는 몇 가지 개별 함수를 내보내고
Redux와 함께 일반적으로 사용되는 다른 패키지(예: Reselect 및 Redux-Thunk)에 대한 종속성을 추가합니다.
Redux 툴킷이 Redux 관련 코드를 개선하는 데 도움이 될 수 있는 몇 가지 방법을 살펴보겠습니다.

스토어 설정

모든 Redux 앱은 Redux 스토어를 설정하고 생성해야 합니다. 여기에는 일반적으로 여러 단계가 포함됩니다.
  • 루트 리듀서 함수 가져오기 / 만들기
  • 비동기, 부수효과 논리 처리를 위한 미들웨어 설정
  • Redux DevTools 확장 설정
  • 애플리케이션이 개발용인지 프로덕션용인지에 따른 로직 변경

수동 스토어 설정

Redux 문서의 Configuring Your Store 페이지의 다음 예는 일반적인 스토어 설정 프로세스를 보여줍니다.
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
}​
읽을 만 하지만, 프로세스가 간단하지는 않습니다.
  • 기본 Redux createStore 함수는 위치가 정해진 인수(rootReducer, preloadedState, Enhancer)를 사용합니다.
    • 어떤 매개변수가 어느 것인지 잊어버리기 쉽습니다.
  • 여러 설정을 추가하려는 경우 미들웨어 및 인핸서를 설정하는 프로세스가 혼란스러울 수 있습니다.
  • Redux DevTools Extension 문서는 처음에 확장 도구가 사용 가능한지 확인하기 위해 전역 네임스페이스를 확인하는 일부 손으로 작성한 코드를 사용하도록 합니다.

configureStore로 스토어 설정 간소화​

configureStore는 다음과 같은 방법으로 이러한 문제를 해결합니다.
  • 가독성을 향상시키는 "명명된" 매개변수가 있는 옵션 객체 사용
  • 스토어에 추가하려는 미들웨어 및 인핸서 어레이를 제공하면 자동으로 applyMiddleware 및 compose을 호출해 줍니다.
  • Redux DevTools Extension을 자동으로 활성화 합니다.
또한 configureStore는 기본적으로 각각 특정 목표를 가진 일부 미들웨어를 추가합니다.
  • redux-thunk는 컴포넌트 외부의 동기 및 비동기 논리 액션 처리에 가장 일반적으로 사용되는 미들웨어입니다.
  • 개발 단계에서 상태를 변경하거나 직렬화할 수 없는 값을 사용하는 것과 같은 일반적인 실수를 확인하는 미들웨어를 추가합니다.

스토어 설정 코드를 더 짧고 읽기 쉽게 해주며, 동시에 좋은 디폴트 동작을 얻을 수 있음을 의미합니다.
configureStore API를 사용하는 가장 간단한 방법은 루트 리듀서 함수를 reducer라는 이름의 매개변수로 전달하는 것입니다.

import { configureStore } from '@reduxjs/toolkit'
import rootReducer from './reducers'

const store = configureStore({
  reducer: rootReducer,
})

export default store
"슬라이스 리듀서"로 가득 찬 객체를 전달할 수도 있으며, configureStore는 combineReducer를 호출합니다.
import { configureStore } from '@reduxjs/toolkit'
import usersReducer from './usersReducer'
import postsReducer from './postsReducer'

const store = configureStore({
  reducer: {
    users: usersReducer,
    posts: postsReducer,
  },
})

export default store

이것은 1 depth 레벨의 리듀서에서만 동작합니다.
리듀서를 중첩하려면 CombineReducers를 직접 호출해야 합니다.

 

스토어 설정을 사용자 정의해야 하는 경우 추가 옵션을 전달할 수 있습니다.
Redux Toolkit을 사용하는 핫 리로딩 예제는 다음과 같습니다.

import { configureStore } from '@reduxjs/toolkit'

import monitorReducersEnhancer from './enhancers/monitorReducers'
import loggerMiddleware from './middleware/logger'
import rootReducer from './reducers'

export default function configureAppStore(preloadedState) {
  const store = configureStore({
    reducer: rootReducer,
    middleware: (getDefaultMiddleware) =>
      getDefaultMiddleware().concat(loggerMiddleware),
    preloadedState,
    enhancers: [monitorReducersEnhancer],
  })

  if (process.env.NODE_ENV !== 'production' && module.hot) {
    module.hot.accept('./reducers', () => store.replaceReducer(rootReducer))
  }

  return store
}
미들웨어 인수를 제공하면 configureStore는 사용자가 나열된 미들웨어만 사용합니다.
 
사용자 정의 미들웨어와 디폴트 미들웨어를을 함께 사용하려면 콜백 표기법을 사용하고
getDefaultMiddleware를 호출하고, 해당 함수가 반환하는 미들웨어 배열에 결과를 포함할 수 있습니다.

리듀서 작성하기

Reducers는 가장 중요한 개념입니다. 일반적인 리듀서 함수는 다음을 수행해야 합니다.
  • 액션 객체의 타입 필드를 보고 어떻게 응답해야 하는지 확인합니다.
  • 상태를 변경해야 하는 부분의 복사본을 만들고 해당 복사본만 수정하여 상태를 변경할 수 없도록 업데이트합니다.
리듀서에서 어떤 조건부 로직이나 작성할 수 있지만,
가장 일반적인 접근 방식은 단일 필드에 대해 가능한 여러 값을 처리하는 간단한 방법이기 때문에 switch 문입니다.
그러나 많은 사람들이 switch 문을 좋아하지 않습니다.
Redux 문서는 액션 타입에 따라 조회 테이블 역할을 하는 함수를 작성하는 예를 보여주지만
해당 함수를 사용자 정의하는 것은 사용자의 역할입니다.

리듀서 작성과 관련된 다른 일반적인 문제는 상태를 변경할 수 없도록 업데이트하는 것과 관련이 있습니다.
JavaScript의 객체는 불변이 아니므로 중첩된 데이터를 불변하도록 손으로 업데이트하는 것은 어렵고 실수하기 쉽습니다.


createReducer로 리듀서 단순화

"룩업 테이블" 접근 방식이 널리 사용되기 때문에,
Redux 툴킷에는 Redux 문서에 표시된 것과 유사한 createReducer 함수가 포함되어 있습니다.
 
그러나 createReducer 유틸리티에는 더 나은 기능을 제공하는 몇 가지 특별한 "마법"이 있습니다.
내부적으로 Immer 라이브러리를 사용하므로 일부 데이터를 "변경"하는 코드를 작성할 수 있지만
실제로는 불변 업데이트를 적용합니다.
즉, 리듀서에서 실수로 상태를 변경하는 것은 사실상 불가능합니다.
일반적으로 switch 문을 사용하는 모든 Redux 리듀서는 createReducer를 직접 사용하도록 변환할 수 있습니다. (with builder)
스위치의 각 케이스는 createReducer에 전달된 객체의 키가 됩니다.
개체 확산 또는 배열 복사와 같은 변경 불가능한 업데이트 논리는 아마도 직접적인 "돌연변이"로 변환될 수 있습니다.
변경 불가능한 업데이트를 있는 그대로 유지하고 업데이트된 복사본을 반환하는 것도 괜찮습니다.

다음은 createReducer를 사용하는 방법에 대한 몇 가지 예입니다.
switch 문과 변경할 수 없는 업데이트를 사용하는 일반적인 "Todo" 리듀서부터 시작하겠습니다.

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(), state.map(), 객체 스프레드 연산자를 통해 불변을 적용합니다.

 

createReducer를 사용하면 해당 예제를 상당히 줄일 수 있습니다.
불변처럼 상태를 "변경"하는 능력은 깊이 중첩된 상태를 업데이트하려고 할 때 특히 유용합니다. (by immer)

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)
    })
})

createReducer 사용 시 고려할 점

createReducer 함수는 매우 유용할 수 있지만 다음 사항에 유의하십시오.
  • "변이" 코드는 createReducer 함수 내에서만 올바르게 작동합니다.
  • Immer를 사용하면 draft 상태를 "변경"함과 동시에 새 상태 값을 반환하는 것을 혼합할 수 없습니다.
자세한 내용은 createReducer API reference를 참조하세요.

액션 생성자 작성하기

Redux는 액션 객체를 생성하는 프로세스를 캡슐화하는 "액션 생성자" 함수를 작성하도록 권장합니다.
(write "action creator" functions)
꼭 필요한 것은 아니지만 Redux 사용 표준입니다.

 

대부분의 액션 생성자는 매우 간단합니다.
일부 매개변수를 사용하여 특정 타입 필드와 액션 내부에 매개변수가 있는 액션 객체를 반환합니다.
해당 매개변수는 일반적으로 액션 객체를 구성하기 위한 Flux 표준 액션 규칙의 일부인 페이로드라는 필드에 넣습니다.
일반적인 액션 생성자는 다음처럼 생겼습니다.

function addTodo(text) {
  return {
    type: 'ADD_TODO',
    payload: { text },
  }
}

createAction으로 액션 생성자 정의하기

직접 액션 제작자를 작성하는 것은 지겹습니다.
Redux toolkit은 주어진 액션 타입을 사용하는 액션 생성자를 생성하고
해당 인수를 페이로드 필드로 바꾸는 createAction이라는 함수를 제공합니다.

const addTodo = createAction('ADD_TODO')
addTodo({ text: 'Buy milk' })
// {type : "ADD_TODO", payload : {text : "Buy milk"}})

createAction은 또한 결과 페이로드 필드를 사용자 정의하고 선택적으로 메타 필드를 추가할 수 있는 "ready 콜백" 인수를 허용합니다.
ready 콜백으로 액션 생성자를 정의하는 방법에 대한 자세한 내용은 createAction API 참조를 참조하세요.

액션 생성자를 액션 타입으로 사용하기

Redux 리듀서는 상태를 업데이트하는 방법을 결정하기 위해 특정 액션 타입을 찾아야 합니다.
일반적으로 액션 타입 문자열과 액션 생성자 함수를 별도로 정의하여 수행합니다.
Redux Toolkit createAction 함수는 이를 더 쉽게 만들기 위해 몇 가지 트릭을 사용합니다.
  • 첫째, createAction은 액션 생성자의 toString() 메서드를 재정의합니다.
    • 액션 생성자 자체를 "액션 타입" 참조로 사용할 수 있음을 의미합니다.
  • 둘째, 액션 타입은 액션 생성자의 타입 필드에도 정의되어 있습니다.
const actionCreator = createAction('SOME_ACTION_TYPE')

console.log(actionCreator.toString())
// "SOME_ACTION_TYPE"

console.log(actionCreator.type)
// "SOME_ACTION_TYPE"

const reducer = createReducer({}, (builder) => {
  // actionCreator.toString() will automatically be called here
  // also, if you use TypeScript, the action type will be correctly inferred
  builder.addCase(actionCreator, (state, action) => {})

  // Or, you can reference the .type field:
  // if using TypeScript, the action type cannot be inferred that way
  builder.addCase(actionCreator.type, (state, action) => {})
})

즉, 별도의 액션 타입 변수를 작성하여 사용하거나,
const SOME_ACTION_TYPE = "SOME_ACTION_TYPE"과 같은 액션 타입의 이름과 값을 반복할 필요가 없습니다.

 

불행히도, 문자열에 대한 암시적 변환은 switch 문에선 대해 발생하지 않습니다.
switch 문에서 이러한 액션 생성자 중 하나를 사용하려면 actionCreator.toString()을 직접 호출해야 합니다.

const actionCreator = createAction('SOME_ACTION_TYPE')

const reducer = (state = {}, action) => {
  switch (action.type) {
    // ERROR: this won't work correctly!
    case actionCreator: {
      break
    }
    // CORRECT: this will work as expected
    case actionCreator.toString(): {
      break
    }
    // CORRECT: this will also work right
    case actionCreator.type: {
      break
    }
  }
}
TypeScript와 함께 Redux Toolkit을 사용하는 경우,
액션 작성자가 객체 키로 사용될 때 TypeScript 컴파일러는 암시적 toString() 변환을 허용하지 않을 수 있습니다.
  • 이 경우 타입 단언하거나 (actionCreator as string)
  • type 필드를 사용해야 할 수 있습니다.

상태의 슬라이스 만들기

Redux 상태는 일반적으로 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
  }
}​
여기서 필수적인 부분은 리듀서 뿐입니다. 다른 부분을 고려하십시오.
  • 두 위치 모두에서 액션 타입을 인라인 문자열로 작성할 수 있습니다.
  • 액션 생성자는 훌륭하지만 Redux에 필수는 아닙니다. (객체 인라인)
  • 여러 파일을 작성하는 유일한 이유는 코드가 수행하는 액션(기능,작업)에 따라 코드를 구분하는 것이 일반적이기 때문입니다.
"ducks" 파일 구조("ducks" file structure)는 다음과 같이 주어진 슬라이스에 대한 모든 Redux 관련 논리를 단일 파일에 넣을 것을 제안합니다.
// 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
  }
}​

여러 파일이 필요하지 않고 액션 타입 상수의 중복 가져오기를 제거할 수 있기 때문에 작업이 간소화됩니다.
그러나 여전히 액션 타입과 액션 생성자를 직접 작성해야 합니다.

객체 안에 함수 정의

모던 JavaScript에는 객체에서 키와 함수를 모두 정의하는 몇 가지 합법적인 방법이 있으며(이것은 Redux에만 해당되지 않음)
다양한 키 정의와 함수 정의를 믹스 앤 매치할 수 있습니다.
예를 들어, 다음은 객체 내부에서 함수를 정의하는 모든 합법적인 방법입니다.
const keyName = "ADD_TODO4";

const reducerObject = {
    // Explicit quotes for the key name, arrow function for the reducer
    "ADD_TODO1" : (state, action) => { }

    // Bare key with no quotes, function keyword
    ADD_TODO2 : function(state, action){  }

    // Object literal function shorthand
    ADD_TODO3(state, action) { }

    // Computed property
    [keyName] : (state, action) => { }
}​
"object literal function shorthand"를 사용하는 것이 아마도 가장 짧은 코드일 수 있지만 원하는 접근 방식을 자유롭게 사용하십시오.

createSlice로 슬라이스 단순화하기

Redux 툴킷에는
사용자가 제공하는 리듀서 함수의 이름을 기반으로 액션 타입 및 액션 생성자를 자동 생성하는 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는 리듀서 필드에 정의된 모든 함수를 살펴보고,
각 리듀서 이름을 액션 타입으로 사용하는 액션 생성자를 생성합니다.
따라서 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 상태의 자체 부분을 "소유"하지만
모든 액션 타입을 수신하고 상태를 적절하게 업데이트할 수 있어야 합니다.
예를 들어, 많은 다른 슬라이스가 데이터를 지우거나 초기 상태 값으로 재설정하여 "사용자 로그아웃" 액션에 응답하기를 원할 수 있습니다. 상태 모양을 디자인하고 슬라이스를 만들 때 이 점을 염두에 두십시오.

둘째, "순환 참조" 문제가 발생할 수 있습니다.

이로 인해 가져오기가 정의되지 않아 해당 가져오기가 필요한 코드가 손상될 수 있습니다.
특히 "덕스" 또는 슬라이스의 경우
두 개의 서로 다른 파일에 정의된 슬라이스가 모두 다른 파일에 정의된 액션에 응답하려는 경우 발생할 수 있습니다.
이 CodeSandbox 예제는 문제를 보여줍니다.
이 문제가 발생하면 순환 참조를 피하는 방식으로 코드를 재구성해야 할 수 있습니다.
이것은 일반적으로 두 모듈이 가져와서 사용할 수 있는 별도의 공통 파일로 공유 코드를 추출해야 합니다.
  • createAction을 사용하여 별도의 파일에 몇 가지 일반적인 액션 타입을 정의하고
  • 해당 액션 생성자를 각 슬라이스 파일로 가져오고
  • extraReducers 인수를 사용하여 처리할 수 있습니다.
JS에서 순환 종속성 문제를 해결하는 방법 문서에는 이 문제에 도움이 될 수 있는 추가 정보와 예제가 있습니다.
(How to fix circular dependency issues in JS)

비동기 로직과 데이터 가져오기

Redux 저장소는 비동기 논리에 대해 아무것도 모릅니다. 모든 비동기 로직은 스토어 외부에서 발생해야 합니다.
  • 동기식으로 액션을 전달하고,
  • 루트 리듀서 함수를 호출하여 상태를 업데이트하고,
  • 무언가가 변경되었음을 UI에 알리는 방법만 알고 있습니다.

스토어에 디스패치하거나 상태값을 확인하여 비동기 논리가 저장소와 상호 작용하도록 하려면 어떻게 해야 할까요?
바로 여기에서 Redux 미들웨어(Redux middleware)가 등장합니다. 스토어를 확장하고 다음을 수행할 수 있습니다.

  • 액션이 전달될 때 추가 논리 실행(예: 액션 및 상태 기록)
  • 디스패치된 액션 버리기, 수정, 지연, 교체 또는 일시 중지
  • dispatch 및 getState에 대한 액세스 권한이 있는 추가 코드 작성
  • 함수 및 프로미스과 같은 일반 작업 개체 이외의 다른 값을 가로채서 대신 실제 액션 개체를 전달하여 받는 방법을 디스패치에 가르칩니다.

미들웨어를 사용하는 가장 일반적인 이유는 다양한 종류의 비동기 논리가 저장소와 상호 작용할 수 있도록 하는 것입니다.
(The most common reason to use middleware is to allow different kinds of async logic to interact with the store)
이를 통해 액션을 전달하고 스토어 상태를 확인할 수 있는 코드를 작성하면서 해당 논리를 UI와 분리할 수 있습니다.

Redux에는 여러 종류의 비동기 미들웨어가 있으며 각각 다른 구문을 사용하여 논리를 작성할 수 있습니다.
가장 일반적인 비동기 미들웨어는 다음과 같습니다.

  • redux-thunk
    • 비동기 논리를 직접 포함할 수 있는 일반 함수를 작성할 수 있습니다.
  • redux-saga
    • 미들웨어에서 실행할 수 있는 동작 설명을 반환하는 제너레이터 함수를 사용합니다.
  • redux-observable
    • RxJS 옵저버블 라이브러리를 사용하여 액션을 처리하는 함수 체인을 생성합니다.
이러한 각 라이브러리에는 서로 다른 사용 사례와 장단점이 있습니다.
Redux 툴킷의 RTK 쿼리는 Redux 앱을 위해 특별히 제작된 데이터 가져오기 및 캐싱 솔루션이며
데이터 가져오기를 관리하기 위해 썽크 또는 리듀서를 작성할 필요가 없습니다.
RTK Query data fetching API
 

RTK Query Overview | Redux Toolkit

RTK Query > Overview: a summary of the RTK Query data caching API for Redux Toolkit

redux-toolkit.js.org

 
데이터 가져오기 로직을 ​​직접 작성해야 하는 경우 Redux Thunk 미들웨어를 표준 접근 방식으로 사용하는 것이 좋습니다.
Redux Thunk 미들웨어는 가장 일반적인 사용 사례(기본 AJAX 데이터 가져오기 등)에 충분하기 때문입니다.
또한 썽크에서 async/await 구문을 사용하면 썽크를 더 쉽게 읽을 수 있습니다.

Redux Toolkit configureStore 기능은 기본적으로 썽크 미들웨어를 자동으로 설정하므로
(automatically sets up the thunk middleware by default)
애플리케이션 코드의 일부로 썽크 작성을 즉시 시작할 수 있습니다.

비동기 로직을 슬라이스로 정의하기

Redux 툴킷은 현재 썽크 함수를 작성하기 위한 특별한 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
      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의 데이터 가져오기 로직은 일반적으로 예측 가능한 패턴을 따릅니다.
  • 요청이 진행 중임을 나타내기 위해 요청 전에 "start" 액션이 전달됩니다.
    • 이는 로드 상태를 추적하거나 중복 요청을 건너뛸 수 있도록 하거나 UI에 로드 표시기를 표시하는 데 사용할 수 있습니다.
  • 비동기 요청이 이루어집니다
    • 요청 결과에 따라 비동기 논리는 결과 데이터가 포함된 "success" 액션이나 오류 세부 정보가 포함된 "실패" 액션을 전달합니다.
      • 리듀서 로직은 두 경우 모두 로딩 상태를 지우고 성공 사례의 결과 데이터를 처리하거나 UI에 나타내기 위해 오류 값을 저장합니다.

이러한 단계는 필수는 아니지만 Redux 자습서에서 제안된 패턴으로 권장됩니다.
(recommended in the Redux tutorials as a suggested pattern)

일반적인 구현은 다음과 같습니다.

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는 액션 타입 및 액션 생성자를 생성하고 해당 액션을 전달하는 썽크를 생성하여 이 패턴을 추상화합니다.

createAsyncThunk와 비동기 요청

개발자는
  • API 요청을 수행하는 데 필요한 실제 로직
  • Redux 액션 히스토리 로그에 표시되는 액션 타입 이름,
  • 리듀서가 가져온 데이터를 처리하는 방법에

가장 관심이 있을 것입니다.
여러 액션 타입을 정의하고 올바른 순서로 액션을 전달하는 반복적인 세부 사항은 중요하지 않습니다.

createAsyncThunk는 이 프로세스를 단순화합니다.
액션 타입 접두사 문자열,
실제 비동기 논리를 수행하고 결과와 함께 약속을 반환하는 페이로드 생성기 콜백만 제공하면 됩니다.
그 대가로 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: (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))​

썽크 액션 생성자는 페이로드 작성자 콜백에 첫 번째 인수로 전달되는 단일 인수를 허용합니다.
페이로드 생성자는 또한 일반적으로 표준 Redux 썽크 함수에 전달되는 매개변수가 포함된 thunkAPI 개체와
자동 생성된 고유한 임의 요청 ID 문자열 및 AbortController.signal object를 받습니다.

interface ThunkAPI {
  dispatch: Function
  getState: Function
  extra?: any
  requestId: string
  signal: AbortSignal
}
페이로드 콜백 내에서 필요에 따라 이들 중 하나를 사용하여 최종 결과를 결정할 수 있습니다.

정규화된 데이터 다루기

대부분의 애플리케이션은 일반적으로 깊이 중첩되거나, 관계형인 데이터를 처리합니다.
데이터 정규화의 목표는 해당 데이터를 이용해 상태를 효율적으로 구성하는 것입니다.
  • 이것은 일반적으로 id의 키를 가진 객체로 컬렉션을 저장하는 동시에
  • 해당 id의 정렬된 배열을 저장하여 수행됩니다.
더 자세한 설명과 추가 예제는 아래 링크를 참조하세요

직접 코딩해서 정규화하기

데이터 정규화에는 특별한 라이브러리가 필요하지 않습니다.
다음은 손을 사용하여
{ users: [{id: 1, first_name: 'normalized', last_name: 'person'}] } 형태의 데이터를 반환하는
fetchAll API 요청의 응답을 정규화하는 방법에 대한 기본 예입니다.
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)
    })
  },
})
 

우리는 이 코드를  직접 작성할 수 있지만, 여러 타입의 데이터를 처리하는 경우에는 반복적입니다.
또한 이 예제에서는 항목을 업데이트하지 않고 상태로 로드하는 작업만 처리합니다.

normalizr와 함께 정규화

normalizr은 데이터를 정규화하는 데 널리 사용되는 기존 라이브러리입니다.

Redux 없이 단독으로 사용할 수 있지만 매우 일반적으로 Redux와 함께 사용됩니다.
일반적인 사용법은 API 응답에서 컬렉션을 형식화한 다음 리듀서에서 처리하는 것입니다.

import { createSlice, createAsyncThunk } from '@reduxjs/toolkit'
import { normalize, schema } from 'normalizr'

import userAPI from './userAPI'

const userEntity = new schema.Entity('users')

export const fetchUsers = createAsyncThunk('users/fetchAll', async () => {
  const response = await userAPI.fetchAll()
  // Normalize the data before passing it to our reducer
  const normalized = normalize(response.data, [userEntity])
  return normalized.entities
})

export const slice = createSlice({
  name: 'users',
  initialState: {
    ids: [],
    entities: {},
  },
  reducers: {},
  extraReducers: (builder) => {
    builder.addCase(fetchUsers.fulfilled, (state, action) => {
      state.entities = action.payload.users
      state.ids = Object.keys(action.payload.users)
    })
  },
})

직접 작성한 버전과 마찬가지로 이것은 상태에 항목을 추가하거나 나중에 업데이트하는 것을 처리하지 않습니다.
수신된 모든 항목을 로드하는 것뿐입니다.

 

createEntityAdapter와 함께 정규화

Redux Toolkit의 createEntityAdapter API는 컬렉션을 가져와
{ ids: [], entities: {} } 형태로
데이터를 슬라이스에 저장하는 표준화된 방법을 제공합니다.
이 미리 정의된 상태 모양과 데이터를 갖고 작업하는 방법을 알고 있는 일련의 리듀서 함수 및 셀렉터를 생성합니다.
import {
  createSlice,
  createAsyncThunk,
  createEntityAdapter,
} from '@reduxjs/toolkit'
import userAPI from './userAPI'

export const fetchUsers = createAsyncThunk('users/fetchAll', async () => {
  const response = await userAPI.fetchAll()
  // In this case, `response.data` would be:
  // [{id: 1, first_name: 'Example', last_name: 'User'}]
  return response.data
})

export const updateUser = createAsyncThunk('users/updateOne', async (arg) => {
  const response = await userAPI.updateUser(arg)
  // In this case, `response.data` would be:
  // { id: 1, first_name: 'Example', last_name: 'UpdatedLastName'}
  return response.data
})

export const usersAdapter = createEntityAdapter()

// By default, `createEntityAdapter` gives you `{ ids: [], entities: {} }`.
// If you want to track 'loading' or other keys, you would initialize them here:
// `getInitialState({ loading: false, activeRequestId: null })`
const initialState = usersAdapter.getInitialState()

export const slice = createSlice({
  name: 'users',
  initialState,
  reducers: {
    removeUser: usersAdapter.removeOne,
  },
  extraReducers: (builder) => {
    builder.addCase(fetchUsers.fulfilled, usersAdapter.upsertMany)
    builder.addCase(updateUser.fulfilled, (state, { payload }) => {
      const { id, ...changes } = payload
      usersAdapter.updateOne(state, { id, changes })
    })
  },
})

const reducer = slice.reducer
export default reducer

export const { removeUser } = slice.actions
CodeSandbox에서 이 예제 사용법의 전체 코드를 볼 수 있습니다.

정규화 라이브러리와 함께 createEntityAdapter 사용하기​

normalizr 또는 다른 normalization 라이브러리를 이미 사용하고 있다면
createEntityAdapter와 함께 사용하는 것을 고려할 수 있습니다.
위의 예를 확장하기 위해 normalizr을 사용하여 페이로드를 포맷한 다음
createEntityAdapter가 제공하는 유틸리티를 활용하는 방법을 보여줍니다.
기본적으로 setAll, addMany 및 upsertMany CRUD 메서드는 엔터티 배열을 예상합니다.
그러나 대안으로 { 1: { id: 1, ... }} 형태의 객체를 전달할 수도 있으므로
사전에 정규화된 데이터를 더 쉽게 삽입할 수 있습니다.
// features/articles/articlesSlice.js
import {
  createSlice,
  createEntityAdapter,
  createAsyncThunk,
  createSelector,
} from '@reduxjs/toolkit'
import fakeAPI from '../../services/fakeAPI'
import { normalize, schema } from 'normalizr'

// Define normalizr entity schemas
export const userEntity = new schema.Entity('users')
export const commentEntity = new schema.Entity('comments', {
  commenter: userEntity,
})
export const articleEntity = new schema.Entity('articles', {
  author: userEntity,
  comments: [commentEntity],
})

const articlesAdapter = createEntityAdapter()

export const fetchArticle = createAsyncThunk(
  'articles/fetchArticle',
  async (id) => {
    const data = await fakeAPI.articles.show(id)
    // Normalize the data so reducers can load a predictable payload, like:
    // `action.payload = { users: {}, articles: {}, comments: {} }`
    const normalized = normalize(data, articleEntity)
    return normalized.entities
  }
)

export const slice = createSlice({
  name: 'articles',
  initialState: articlesAdapter.getInitialState(),
  reducers: {},
  extraReducers: (builder) => {
    builder.addCase(fetchArticle.fulfilled, (state, action) => {
      // Handle the fetch result by inserting the articles here
      articlesAdapter.upsertMany(state, action.payload.articles)
    })
  },
})

const reducer = slice.reducer
export default reducer

// features/users/usersSlice.js

import { createSlice, createEntityAdapter } from '@reduxjs/toolkit'
import { fetchArticle } from '../articles/articlesSlice'

const usersAdapter = createEntityAdapter()

export const slice = createSlice({
  name: 'users',
  initialState: usersAdapter.getInitialState(),
  reducers: {},
  extraReducers: (builder) => {
    builder.addCase(fetchArticle.fulfilled, (state, action) => {
      // And handle the same fetch result by inserting the users here
      usersAdapter.upsertMany(state, action.payload.users)
    })
  },
})

const reducer = slice.reducer
export default reducer

// features/comments/commentsSlice.js

import { createSlice, createEntityAdapter } from '@reduxjs/toolkit'
import { fetchArticle } from '../articles/articlesSlice'

const commentsAdapter = createEntityAdapter()

export const slice = createSlice({
  name: 'comments',
  initialState: commentsAdapter.getInitialState(),
  reducers: {},
  extraReducers: (builder) => {
    builder.addCase(fetchArticle.fulfilled, (state, action) => {
      // Same for the comments
      commentsAdapter.upsertMany(state, action.payload.comments)
    })
  },
})

const reducer = slice.reducer
export default reducer
CodeSandbox에서 이 예제 사용법의 전체 코드를 볼 수 있습니다.

createEntityAdapter와 함께 selector 사용하기

엔티티 어댑터는 가장 일반적인 셀렉터를 생성하는 셀렉터 팩토리를 제공합니다.
위의 예를 사용하여 다음과 같이 usersSlice에 셀렉터를 추가할 수 있습니다.

// Rename the exports for readability in component usage
export const {
  selectById: selectUserById,
  selectIds: selectUserIds,
  selectEntities: selectUserEntities,
  selectAll: selectAllUsers,
  selectTotal: selectTotalUsers,
} = usersAdapter.getSelectors((state) => state.users)
컴포넌트에서 이러한 셀렉터를 사용할 수 있습니다.
import React from 'react'
import { useSelector } from 'react-redux'
import { selectTotalUsers, selectAllUsers } from './usersSlice'

import styles from './UsersList.module.css'

export function UsersList() {
  const count = useSelector(selectTotalUsers)
  const users = useSelector(selectAllUsers)

  return (
    <div>
      <div className={styles.row}>
        There are <span className={styles.value}>{count}</span> users.{' '}
        {count === 0 && `Why don't you fetch some more?`}
      </div>
      {users.map((user) => (
        <div key={user.id}>
          <div>{`${user.first_name} ${user.last_name}`}</div>
        </div>
      ))}
    </div>
  )
}

대체 ID 필드 지정​

기본적으로 createEntityAdapter는 데이터의 entity.id 필드에 고유 ID가 있다고 가정합니다.
데이터 세트가 해당 ID를 다른 필드에 저장하는 경우 적절한 필드를 반환하는 selectId 인수를 전달할 수 있습니다.

// In this instance, our user data always has a primary key of `idx`
const userData = {
  users: [
    { idx: 1, first_name: 'Test' },
    { idx: 2, first_name: 'Two' },
  ],
}

// Since our primary key is `idx` and not `id`,
// pass in an ID selector to return that field instead
export const usersAdapter = createEntityAdapter({
  selectId: (user) => user.idx,
})

엔터티 정렬하기

createEntityAdapter는 상태의 ID 컬렉션을 정렬하는 데 활용할 수 있는 sortComparer 인수를 제공합니다.
이것은 정렬 순서를 보장하고 데이터가 미리 정렬되지 않은 경우에 매우 유용할 수 있습니다.

// In this instance, our user data always has a primary key of `id`, so we do not need to provide `selectId`.
const userData = {
  users: [
    { id: 1, first_name: 'Test' },
    { id: 2, first_name: 'Banana' },
  ],
}

// Sort by `first_name`. `state.ids` would be ordered as
// `ids: [ 2, 1 ]`, since 'B' comes before 'T'.
// When using the provided `selectAll` selector, the result would be sorted:
// [{ id: 2, first_name: 'Banana' }, { id: 1, first_name: 'Test' }]
export const usersAdapter = createEntityAdapter({
  sortComparer: (a, b) => a.first_name.localeCompare(b.first_name),
})

 

직렬화 불가능한 데이터로 작업하기

Redux의 핵심 사용 원칙 중 하나는 직렬화할 수 없는 값을 상태나 액션에 넣으면 안 된다는 것입니다.
(you should not put non-serializable values in state or actions)

 

그러나 대부분의 규칙과 마찬가지로 예외가 있습니다.
직렬화할 수 없는 데이터를 수락해야 하는 액션을 처리해야 하는 경우가 있습니다.
반드시 필요한 경우에만 수행되어야 하며
이러한 직렬화 불가능한 페이로드는 리듀서를 통해 애플리케이션 상태로 만들지 않아야 합니다.

직렬화 가능성 검사 미들웨어(serializability dev check middleware)는

액션이나 상태에서 직렬화할 수 없는 값을 감지할 때마다 자동으로 경고합니다.
실수로 직렬화 불가능한 값을 상태에 넣지 하지 않도록 이 미들웨어를 활성 상태로 두는 것이 좋습니다.
그러나 이러한 경고를 해제해야 하는 경우 특정 액션 타입 또는 액션 및 상태의 필드를 무시하도록 설정하여
미들웨어를 사용자 지정할 수 있습니다.

configureStore({
  //...
  middleware: (getDefaultMiddleware) =>
    getDefaultMiddleware({
      serializableCheck: {
        // Ignore these action types
        ignoredActions: ['your/action/type'],
        // Ignore these field paths in all actions
        ignoredActionPaths: ['meta.arg', 'payload.timestamp'],
        // Ignore these paths in the state
        ignoredPaths: ['items.dates'],
      },
    }),
})

Redux-Presist 사용하기

Redux-Persist를 사용하는 경우 직렬화 가능성 검사 미들웨어에 무시할 모든 액션 타입을 ​​특정해야 합니다.
import { configureStore } 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 App from './App'
import rootReducer from './reducers'

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

const persistedReducer = persistReducer(persistConfig, rootReducer)

const store = configureStore({
  reducer: persistedReducer,
  middleware: (getDefaultMiddleware) =>
    getDefaultMiddleware({
      serializableCheck: {
        ignoredActions: [FLUSH, REHYDRATE, PAUSE, PERSIST, PURGE, REGISTER],
      },
    }),
})

let persistor = persistStore(store)

ReactDOM.render(
  <Provider store={store}>
    <PersistGate loading={null} persistor={persistor}>
      <App />
    </PersistGate>
  </Provider>,
  document.getElementById('root')
)
또한, persistence.purge()를 호출할 때 지우려는 특정 슬라이스에 추가 리듀서를 추가하여 영속된 상태를 제거할 수 있습니다.
이는 디스패치된 로그아웃 액션에서 영속된 상태를 지우려는 경우에 특히 유용합니다.
import { PURGE } from "redux-persist";

...
extraReducers: (builder) => {
    builder.addCase(PURGE, (state) => {
        customEntityAdapter.removeAll(state);
    });
}
 
또한 RTK 쿼리로 설정한 모든 API를 블랙리스트에 추가하는 것이 좋습니다.
api 슬라이스 감속기가 블랙리스트에 없으면 api 캐시가 자동으로 유지되고 복원되어
더 이상 존재하지 않는 컴포넌트의 팬텀 구독을 남길 수 있습니다.
설정은 다음과 같아야 합니다.
const persistConfig = {
  key: "root",
  version: 1,
  storage,
  blacklist: [pokemonApi.reducerPath],
};

아래 게시물도 참조하면 도움이 됩니다.

Use with React-Redux-Firebase

RRF는 3.x의 대부분의 액션 및 상태에 타임스탬프 값을 포함하지만 4.x부터 해당 동작을 개선할 수 있는 PR이 있습니다.
설정은 다음과 같습니다. (직렬화 가능성 검사 무시)
import { configureStore } from '@reduxjs/toolkit'
import {
  getFirebase,
  actionTypes as rrfActionTypes,
} from 'react-redux-firebase'
import { constants as rfConstants } from 'redux-firestore'
import rootReducer from './rootReducer'

const store = configureStore({
  reducer: rootReducer,
  middleware: (getDefaultMiddleware) =>
    getDefaultMiddleware({
      serializableCheck: {
        ignoredActions: [
          // just ignore every redux-firebase and react-redux-firebase action type
          ...Object.keys(rfConstants.actionTypes).map(
            (type) => `${rfConstants.actionsPrefix}/${type}`
          ),
          ...Object.keys(rrfActionTypes).map(
            (type) => `@@reactReduxFirebase/${type}`
          ),
        ],
        ignoredPaths: ['firebase', 'firestore'],
      },
      thunk: {
        extraArgument: {
          getFirebase,
        },
      },
    }),
})

export default store
반응형