Redux toolkit 공식 문서에서 Immer의 올바른 활용법을 배워봅니다.
https://redux-toolkit.js.org/usage/immer-reducers
Immer와 함께 Reducer 작성하기
변경 불가능한 업데이트 로직을 손으로 작성하는 것은 어렵고,
리듀서에서 실수로 상태를 변경하는 것은 Redux 사용자가 저지르는 가장 흔한 실수입니다.
Redux Toolkit의 createReducer 및 createSlice는 자동으로 Immer를 내부적으로 사용하여 "변이" 구문을 사용하여
간단하게 불변 업데이트 논리를 작성할 수 있도록 합니다.
리듀서 구현을 단순화하는 데 도움이 됩니다.
가변 업데이트를 하면 안되는 이유
// ❌ Illegal - by default, this will mutate the state!
state.value = 123
- 최신 값을 표시하기 위해 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)
})
})
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 내부에 래핑될 때만 올바르게 작동한다는 것을 기억하십시오!
그렇지 않으면 해당 코드가 실제로 데이터를 변경합니다.
Immer 사용 패턴
상태 조작 후 상태 리턴하기
어떤 경우에든 리듀서에서 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
}
}
})
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)
대부분의 경우 이것은 중요하지 않지만 값을 삽입한 다음 추가로 업데이트하려는 경우가 있을 수 있습니다.
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 참조에 대한 시각적 가이드
Immer
'FrontEnd' 카테고리의 다른 글
[typescript] d.ts 파일을 js 프로젝트에서 사용할 수 있을까? (3) | 2022.09.14 |
---|---|
Redux Toolkit : Usage Guide(사용 가이드) (0) | 2022.09.13 |
[번역] Mobx로 배우는 반응형 프로그래밍(reactive programming)의 원리 (0) | 2022.09.12 |
[30초 CSS] CSS 속성 상속과 !important는 무관하다 (0) | 2022.09.02 |
리액트 쿼리 : 셀렉터와 폴더 구조 (0) | 2022.08.30 |