본문 바로가기

FrontEnd

Redux Toolkit : Immer와 함께 사용하기

반응형

Redux toolkit 공식 문서에서 Immer의 올바른 활용법을 배워봅니다.

https://redux-toolkit.js.org/usage/immer-reducers

 

Writing Reducers with Immer | Redux Toolkit

 

redux-toolkit.js.org

Immer와 함께 Reducer 작성하기

변경 불가능한 업데이트 로직을 손으로 작성하는 것은 어렵고,
리듀서에서 실수로 상태를 변경하는 것은 Redux 사용자가 저지르는 가장 흔한 실수입니다.

 

Redux Toolkit의 createReducer 및 createSlice는 자동으로 Immer를 내부적으로 사용하여 "변이" 구문을 사용하여
간단하게 불변 업데이트 논리를 작성할 수 있도록 합니다.
리듀서 구현을 단순화하는 데 도움이 됩니다.


가변 업데이트를 하면 안되는 이유

Redux의 기본 규칙 중 하나는 리듀서가 원래/현재 상태 값을 변경할 수 없다는 것입니다.
// ❌ Illegal - by default, this will mutate the state!
state.value = 123
Redux에서 상태를 변경하지 말아야 하는 몇 가지 이유가 있습니다.
  • 최신 값을 표시하기 위해 UI가 제대로 업데이트 되지 않는 등의 버그가 발생합니다.
  • 상태가 업데이트된 이유와 방법을 이해하기 어렵게 만듭니다.
  • 테스트 작성을 어렵게 만듭니다.
  • "시간 여행 디버깅"을 올바르게 사용하는 기능이 중단됩니다.
  • Redux의 의도된 정신과 사용 패턴에 어긋납니다.

Redux Toolkit과 Immer

Redux Toolkit의 createReducer API는 내부적으로 자동으로 Immer를 사용합니다.
따라서 createReducer에 전달되는 모든 리듀서 함수 내부에서 상태를 "변경"하는 것이 이미 안전합니다.

const todosReducer = createReducer([], (builder) => {
  builder.addCase('todos/todoAdded', (state, action) => {
    // "mutate" the array by calling push()
    state.push(action.payload)
  })
})​
createSlice는 내부에서 createReducer를 사용하므로 상태를 "변경"하는 것도 안전합니다.
const todosSlice = createSlice({
  name: 'todos',
  initialState: [],
  reducers: {
    todoAdded(state, action) {
      state.push(action.payload)
    },
  },
})

이는 케이스 리듀서 함수가 createSlice/createReducer 호출 외부에서 정의된 경우에도 적용됩니다.
예를 들어, 상태를 "변경"하고 필요에 따라 해당 상태를 포함하는 재사용 가능한 케이스 리듀서 함수를 가질 수 있습니다.

const addItemToArray = (state, action) => {
  state.push(action.payload)
}

const todosSlice = createSlice({
  name: 'todos',
  initialState: [],
  reducers: {
    todoAdded: addItemToArray,
  },
})
변이" 논리가 실행될 때 내부적으로 Immer의 produce 메서드에 래핑되기 때문입니다.
"변이" 논리는 Immer 내부에 래핑될 때만 올바르게 작동한다는 것을 기억하십시오!
그렇지 않으면 해당 코드가 실제로 데이터를 변경합니다.

Immer 사용 패턴

Redux Toolkit에서 Immer를 사용할 때 알아야 할 몇 가지 유용한 패턴과 주의해야 할 문제가 있습니다.
 

상태 조작 후 상태 리턴하기

Immer는 중첩 필드에 할당하거나 값을 변경하는 함수를 호출하여 기존 draft 상태 값을 변경하려는 시도를 추적하여 작동합니다.
이는 Immer가 시도된 변경 사항을 볼 수 있도록 상태가 JS 객체 또는 배열이어야 함을 의미합니다.
(슬라이스의 상태는 문자열이나 bool과 같은 프리미티브 일 수 있지만, 프리미티브는 불변이므로 새 값을 반환하기만 하면 됩니다.)

어떤 경우에든 리듀서에서 Immer는 기존 상태를 변경하거나 새 상태 값을 직접 구성하여 반환할 것으로 예상합니다.
그러나 같은 함수에서 두가지 논리를 다 사용하면 안됩니다!

(새 상태를 반환하거나, 변경하거나)

예를 들어, 이 두 가지는 모두 Immer에서 유효한 리듀서 입니다.

const todosSlice = createSlice({
  name: 'todos',
  initialState: [],
  reducers: {
    todoAdded(state, action) {
      // "Mutate" the existing state, no return value needed
      state.push(action.payload)
    },
    todoDeleted(state, action.payload) {
      // Construct a new result array immutably and return it
      return state.filter(todo => todo.id !== action.payload)
    }
  }
})

그러나 불변 업데이트를 사용하여 작업의 일부를 수행한 다음 "변이"를 통해 결과를 저장할 수 있습니다.
예를 들어 중첩 배열을 필터링할 수 있습니다.

const todosSlice = createSlice({
  name: 'todos',
  initialState: {todos: [], status: 'idle'}
  reducers: {
    todoDeleted(state, action.payload) {
      // Construct a new array immutably
      const newTodos = state.todos.filter(todo => todo.id !== action.payload)
      // "Mutate" the existing state to save the new array
      state.todos = newTodos
    }
  }
})
암시적 반환이 있는 화살표 함수의 상태를 변경하면 이 규칙이 깨지고 오류가 발생합니다!
이는 명령문과 함수 호출이 값을 각각 반환할 수 있기 때문에,
Immer가 시도된 변형과 새로운 반환 값을 모두 보고 결과로 사용할 값을 모르기 때문입니다.
일부 잠재적인 솔루션은 void 키워드를 사용하여 반환 값을 건너뛰거나
중괄호를 사용하여 화살표 함수에 반환 값이 없는 본문을 제공하는 것입니다.
const todosSlice = createSlice({
  name: 'todos',
  initialState: [],
  reducers: {
    // ❌ ERROR: mutates state, but also returns new array size!
    brokenReducer: (state, action) => state.push(action.payload),
    // ✅ SAFE: the `void` keyword prevents a return value
    fixedReducer1: (state, action) => void state.push(action.payload),
    // ✅ SAFE: curly braces make this a function body and no return
    fixedReducer2: (state, action) => {
      state.push(action.payload)
    },
  },
})​
중첩된 불변 업데이트 논리를 작성하는 것은 어렵지만
개별 필드를 할당하는 것보다 한 번에 여러 필드를 업데이트하는 개체 확산 작업을 수행하는 것이 더 간단한 경우가 있습니다.
function objectCaseReducer1(state, action) {
  const { a, b, c, d } = action.payload
  return {
    ...state,
    a,
    b,
    c,
    d,
  }
}

function objectCaseReducer2(state, action) {
  const { a, b, c, d } = action.payload
  // This works, but we keep having to repeat `state.x =`
  state.a = a
  state.b = b
  state.c = c
  state.d = d
}

 

Object.assign은 항상 주어진 첫 번째 객체를 변경하기 때문에
Object.assign을 사용하여 여러 필드를 한 번에 변경할 수 있습니다.

function objectCaseReducer3(state, action) {
  const { a, b, c, d } = action.payload
  Object.assign(state, { a, b, c, d })
}

상태를 재설정, 대체하기

새 데이터를 로드했거나 상태를 초기 값으로 재설정하려는 경우
기존 상태 전체를 교체해야 하는 경우가 있습니다.

주의!!!

일반적인 실수는 state = someValue를 직접 할당하는 것입니다.
이것은 지역 상태 변수를 다른 참조로 가리킵니다.
이는 메모리의 기존 상태 개체/배열을 변경하거나 완전히 새로운 값을 반환하지 않으므로
Immer는 실제 변경을 수행하지 않습니다.
대신 기존 상태를 바꾸려면 새 값을 직접 반환해야 합니다.
const initialState = []
const todosSlice = createSlice({
  name: 'todos',
  initialState,
  reducers: {
    brokenTodosLoadedReducer(state, action) {
      // ❌ ERROR: does not actually mutate or return anything new!
      state = action.payload
    },
    fixedTodosLoadedReducer(state, action) {
      // ✅ CORRECT: returns a new value to replace the old one
      return action.payload
    },
    correctResetTodosReducer(state, action) {
      // ✅ CORRECT: returns a new value to replace the old one
      return initialState
    },
  },
})

 Draft 상태 조사 및 디버깅하기

업데이트되는 동안의 모습을 보기 위해 리듀서에서 진행 중인 상태를 로깅하려는 것은 일반적입니다.

  • (console.log(state))

불행히도 브라우저는 읽거나 이해하기 어려운 형식으로 프록시 인스턴스를 표시합니다.

이 문제를 해결하기 위해 Immer에는 래핑된 데이터의 복사본을 추출하는 current 함수를 포함하고 있습니다.

(Immer includes a current function that extracts a copy of the wrapped data)

RTK는 current를 re export 합니다.

작업 진행 상태를 로깅하거나 조사해야 하는 경우 리듀서에서 이것을 사용할 수 있습니다.

import { current } from '@reduxjs/toolkit'

const todosSlice = createSlice({
  name: 'todos',
  initialState: todosAdapter.getInitialState(),
  reducers: {
    todoToggled(state, action) {
      // ❌ ERROR: logs the Proxy-wrapped data
      console.log(state)
      // ✅ CORRECT: logs a plain JS copy of the current data
      console.log(current(state))
    },
  },
})
올바른 출력은 다음과 같습니다.

Immer는 또한 업데이트를 적용하지 않은 원본 데이터를 확인할 수 있는 original 함수와,
값이 프록시로 래핑된 draft인지 검사하는 isDraft 함수를 제공합니다.

(original and isDraft functions)

RTK 1.5.1부터 둘 다 RTK에서 re export 합니다.


중첩 데이터 업데이트

Immer는 중첩 데이터 업데이트를 크게 단순화합니다. 
중첩된 개체와 배열도 프록시로 래핑되고 draft가 작성되며,
중첩된 값을 변수로 가져온 다음 변경하는 것은 안전합니다.

 

그러나 이것은 여전히 ​​객체와 배열에만 적용됩니다.
프리미티브 값을 자체 변수로 가져와서 업데이트하려고 하면
Immer는 래핑할 것이 없고 업데이트를 추적할 수 없습니다.

const todosSlice = createSlice({
  name: 'todos',
  initialState: [],
  reducers: {
    brokenTodoToggled(state, action) {
      const todo = state.find((todo) => todo.id === action.payload)
      if (todo) {
        // ❌ ERROR: Immer can't track updates to a primitive value!
        let { completed } = todo
        completed = !completed
      }
    },
    fixedTodoToggled(state, action) {
      const todo = state.find((todo) => todo.id === action.payload)
      if (todo) {
        // ✅ CORRECT: This object is still wrapped in a Proxy, so we can "mutate" it
        todo.completed = !todo.completed
      }
    },
  },
})

여기에 문제가 있습니다. Immer는 상태에 새로 삽입된 개체를 래핑하지 않습니다.
(Immer will not wrap objects that are newly inserted into the state)
대부분의 경우 이것은 중요하지 않지만 값을 삽입한 다음 추가로 업데이트하려는 경우가 있을 수 있습니다.

이와 관련해, RTK의 createEntityAdapter 업데이트 함수를 독립적인 리듀서로 사용하거나,
해당 함수를 이용해 리듀서의 업데이트 함수를 "변경"할 수 있습니다.
이러한 함수는 주어진 상태가 draft로 래핑되었는지 확인하여 새 값을 "변경"할지 또는 반환할지 여부를 결정합니다.
케이스 리듀서 내에서 이러한 함수를 직접 호출하는 경우 draft 값을 전달하는지 플레인 값을 전달하는지 확인하십시오.
 
마지막으로, Immer가 자동으로 중첩된 객체나 배열을 생성하지 않는다는 점이 중요합니다.
직접 생성해야 합니다.
예를 들어 중첩된 배열이 포함된 조회 테이블이 있고 이러한 배열 중 하나에 항목을 삽입하려고 한다고 가정해 보겠습니다.
해당 배열의 존재 여부를 확인하지 않고 무조건 삽입을 시도하면 배열이 존재하지 않을 때 논리가 충돌합니다.
대신 배열이 먼저 존재하는지 확인해야 합니다.
const itemsSlice = createSlice({
  name: 'items',
  initialState: { a: [], b: [] },
  reducers: {
    brokenNestedItemAdded(state, action) {
      const { id, item } = action.payload
      // ❌ ERROR: will crash if no array exists for `id`!
      state[id].push(item)
    },
    fixedNestedItemAdded(state, action) {
      const { id, item } = action.payload
      // ✅ CORRECT: ensures the nested array always exists first
      if (!state[id]) {
        state[id] = []
      }

      state[id].push(item)
    },
  },
})

상태 변경 린팅하기

많은 ESLint 설정에는

중첩 필드의 변형에 대해 경고할 수도 있는 https://eslint.org/docs/rules/no-param-reassign 규칙이 포함됩니다.

이로 인해 규칙이 Immer-powered reducers에서 상태에 대한 돌연변이에 대해 경고할 수 있으며 이는 도움이 되지 않습니다.

이 문제를 해결하려면 ESLint 규칙에 state라는 이름의 매개변수에 대한 변이를 무시하도록 지시할 수 있습니다.

{
  'no-param-reassign': ['error', { props: true, ignorePropertyModificationsFor: ['state'] }]
}

내부적으로 Immer를 사용하는 이유

해당 기능을 optional로 만들어 달라는 요청을 많이 받았지만,

Immer는 redux에 절대적으로 필요합니다.

지금까지 가변 업데이트에 의한 버그가 리덕스 앱의 가장 큰 오류 원인중 하나였기 때문에,

제일 큰 오류 발생 지점을 없앴다는 것은 대단한 성과이며,

코드의 간결성과, api의 혼란 (slice vs splice. sort는 배열 자체를 바꿔버림)을 없앨 수 있는 좋은 개선입니다.

RTK Query에서는 immer의 patch 기능을 통해 낙관적 업데이트를 적용합니다.

(optimistic updates and manual cache updates)

번들 크기는 코드 라인 수 감소를 통해 상쇄 가능합니다.

해당 라이브러리에 의한 성능 오버헤드는 별것 아닙니다. UI 업데이트 오버헤드가 훨씬 더 중요합니다.


참고

JS 참조에 대한 시각적 가이드 

 

A Visual Guide to References in JavaScript

 

daveceddia.com

Immer

 

Introduction to Immer | Immer

Immer

immerjs.github.io

 

반응형