Writing Action Creators

Redux는 action object를 생성하는 과정을 캡슐화하는 "action creator" functions를 사용하도록 장려합니다. 직접적으로 반드시 작성이 필요한 것은 아니지만, Redux 사용법에서 표준적인 과정입니다.

대부분의 action creators는 정말 간단합니다. 몇몇 파라미터를 받고, 특정한 type 필드와 action 내부의 파라미터를 포함한 action object를 반환합니다. 이런 파라미터들은 payload 라고 하는 특정한 필드 내부에 포함됩니다. payload는 action objects의 콘텐츠를 구조화 하기 위한 Flux Standard Action 컨벤션의 일부입니다. 전형적인 action creator는 아마도 이렇게 생겼습니다.

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

Defining Action Creators with createAction

손으로 직접 action creators를 작성하는 것은 귀찮을 수 있습니다. Redux Toolkit은 createAction 이라는 함수를 제공해줍니다. 이 함수는 제공 받은action type을 사용하며, 제공받은 인수를 payload 필드로 변환해주는 action creator를 생성해줍니다.

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

또한 createAction은 결과의 payload를 커스터마이징 하고, 선택적으로 meta 필드를 더할 수 있도록 해주는 "prepare callback" argument를 받을 수 있습니다. prepare callback을 이용한 action creators 정의와 관련된 자세한 내용은 createAction API reference를 참고하시면 됩니다.

Using Action Creators as Action Types

Redux reducers는 어떻게 상태를 업데이트할지 결정하기 위해 특정 action type을 확인해야 합니다. 일반적으로, 이런 과정은 action type string과 action creator functions를 분리하여 정의하는 식으로 이뤄집니다. Redux Toolkit의 createAction 함수는 이를 쉽게하기 위해 몇가지 트릭을 이용합니다.

먼저 createAction은 생성하는 action creators의 toString() 함수를 오버라이드 합니다. 이는 action creator 자체가 "action type"으로 사용될 수 있음을 의미합니다. 예를 들어 builder.addCase에 제공되는 key나 createReducer object notation 등에서 사용될 수 있습니다.

또한, action type은 action creator에서 type 필드로 정의됩니다.

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

이는 분리된 action type 변수를 작성하거나, const SOME_ACTION_TYPE = "SOME_ACTION_TYPE" 과 같은 action type명과 값을 반복적으로 작성할 필요가 없다는 것을 의미합니다.

불행히도, string으로의 암묵적 변환은 switch statements에선 제대로 동작하지 않습니다. 만약 switch statement에서 이런 action creator를 사용하고 싶다면, 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
    }
  }

}

만약 Redux Toolkit을 TypeScript와 사용한다면, action creator가 object key로 사용되는 경우 TypeScript 컴파일러가 toString() 으로의 암묵적 변환을 받아들이지 않을 수 있습니다. 이런 경우 수동적으로 string으로 변환해주거나(actionCreator as string), .type 필드를 key로 사용해야 합니다.

Creating Slices of State

Redux State는 일반적으로 combineReducers에 전달된 reducers에 정의된 조각으로 구성되어 있습니다.

import { combineReducers } from 'redux'
import usersReducer from './usersReducer'
import postsReducer from './postsReducer'

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

이 예시에선, usersposts 모두 조각("slices")로 여겨집니다. 두 reducers 모두:

  • initial value를 포함한 상태의 조각을 "소유" 합니다.
  • 소유한 상태를 어떻게 업데이트 할지 정의합니다.
  • 어떤 특정한 action들이 상태의 업데이트로 이어질지 정의합니다.

일반적인 접근은 상태 조각들의 reducer function을 그들만의 파일에 정의하고, action creators를 두 번째 파일에 정의하는 것입니다. 왜냐하면 두 함수 모두 세번째 파일에 정의되고 import가 필요한 똑같은 action types를 참조할 필요가 있기 때문입니다.

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

위 코드에서 반드시 필요한 부분은 reducer뿐입니다. 다른 부분들을 생각해보면 다음과 같습니다.

  • We could have written the action types as inline strings in both places
  • The action creators are good, but they're not required to use Redux - a component could skip supplying a mapDispatch argument to connect, and just call this.props.dispatch({type: "CREATE_POST", payload: {id: 123, title: "Hello World"}}) itself
  • the only reason we're even writing multiple files is because it's common to separate code by what it does

"ducks" 파일 구조는 다음과 같이 주어진 슬라이스에 대한 모든 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
  }
}

이는 여러 파일을 작성할 필요가 없고, action type constants의 불필요한 import를 제거해주기에 상황을 단순하게 만들어줍니다. 하지만 여전히 action types와 action creators를 스스로 작성해야 합니다.

Defining Functions in Objects

모던 자바스크립트에서, keys와 functions를 object내부에서 정의하는 몇가지 합법적인 방법이 있습니다(이는 Redux에 무관하게 가능합니다). 그리고 다양한 key정의와 function정의를 mix and match 할 수 있습니다. 예를 들어, 아래 방법들은 모두 object내부에 function을 정의하는 합법적인 방법들입니다.

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"를 사용하는 것이 보통 가장 짧은 코드일 것입니다. 하지만 위 방법들 중 원하는 방법으로 편하게 사용하면 됩니다.

Simplifying Slices with createSlice

이 과정을 간단히 하기 위해, Redux Toolkit은 여러분에게 제공받은 reducer functions names를 기준으로 action types와 action creators를 자동으로 생성해주는 createSlice function을 포함합니다.

아래는 createSlice로 구현한 posts예시입니다.

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

createSlicereducers 필드에 정의된 모든 함수를 살펴봅니다.
그리고 제공된 모든 "case reducer" function에 대해 reducer의 이름을 액션 유형 자체로 사용하는 액션 생성기를 생성합니다. 따라서, createPost reducer는 "posts/createPost" action type이 되었습니다. 또한 createPost() action creator는 해당 타입의 action을 리턴합니다.

Exporting and Using Slices

대부분의 경우, slice를 정의하고 그 안의 action creators와 reducers의 export가 필요합니다. 이를 위해 추천되는 ES6 destructuring and export syntax입니다:

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

물론 개발자가 선호한다면 slice object를 직접적으로 export하는 방법도 가능합니다.

이런 방식으로 정의된 Slices는 action creators와 reducers를 정의하고 exporting하는 점에서 "Redux Ducks" pattern과 매우 유사합니다. 한편, slice의 importing, exporting시 알아야 할 몇 가지 잠재적인 단점이 존재합니다.

첫째로, Redux action types는 단일 슬라이스에만 적용되는 것이 아닙니다. 개념적으로, 각각의 slice reducer는 그들 고유의 Redux state 조각을 "소유"하고 있습니다. 하지만, 모든 action type을 수신하고 state를 적절하게 업데이트 할 수 있어야 합니다. 예를 들어, 다양한 slices들이 "user logged out"action에 대해 data를 지우거나, initial state로 돌아가는 응답을 해야 할 수 있습니다. 이런 생각을 state shape을 디자인 하거나 slice를 생성할 때 항상 생각해야 합니다.

두번째로, 만약 두 모듈이 각각을 import하는 경우, JS 모듈은 "circular reference" 문제를 발생시킬 수 있습니다. 이로 인해 import가 정의되지 않아 import가 필요한 코드가 손상될 수 있습니다. 특정 "ducks" case나 slices에서 두 개의 다른 파일에 정의된 slice 모두 다른 한 파일에 정의된 action에 응답하려는 경우 발생할 수 있습니다.

이러한 문제를 마주한다면, circular references를 피하는 방식으로 코드를 재구성 해야합니다. 이는 보통 공유되는 코드를 두 모듈 모두 import 하고 사용할 수 있도록 분리된 common file로 추춣는 작업이 필요합니다. 이 경우, 아마도 createAction을 이용해 몇 가지 common action types를 분리된 파일에 정의하고, 각각의 slice file에 해당 action들을 import하고, extraReducers 아규먼트를 통해 핸들링 하는 과정이 필요할 것입니다.

How to fix circular dependency issues in JS에서 이슈 해결에 도움을 주는 추가적인 정보와 예시를 제공합니다.

출처

profile
웹 개발을 공부하고 있는 윤석주입니다.

0개의 댓글