Перейти к основному содержимому
Неофициальный Бета-перевод

Эта страница переведена PageTurner AI (бета). Не одобрена официально проектом. Нашли ошибку? Сообщить о проблеме →

Шаблоны неизменяемых обновлений

В статьях раздела Базовые концепции > Неизменяемое управление данными приведены хорошие примеры выполнения базовых операций обновления без мутаций, например, изменение поля объекта или добавление элемента в конец массива. Однако редьюсерам часто требуется комбинировать эти операции для решения более сложных задач. Ниже приведены примеры для типичных сценариев, которые вам может потребоваться реализовать.

Обновление вложенных объектов

Ключевой принцип обновления вложенных данных заключается в том, что каждый уровень вложенности должен быть корректно скопирован и обновлён. Эта концепция часто вызывает сложности у изучающих Redux, и при работе с вложенными объектами регулярно возникают определённые проблемы. Они приводят к случайным прямым мутациям, которых следует избегать.

Правильный подход: Копирование всех уровней вложенных данных

К сожалению, корректное применение неизменяемых обновлений к глубоко вложенному состоянию часто приводит к многословному и сложному для чтения коду. Вот как может выглядеть пример обновления state.first.second[someId].fourth:

function updateVeryNestedField(state, action) {
return {
...state,
first: {
...state.first,
second: {
...state.first.second,
[action.someId]: {
...state.first.second[action.someId],
fourth: action.someValue
}
}
}
}
}

Очевидно, что каждый уровень вложенности ухудшает читаемость и увеличивает вероятность ошибок. Это одна из причин, по которой рекомендуется сохранять состояние максимально плоским и по возможности комбинировать редьюсеры.

Типичная ошибка #1: Новые переменные, ссылающиеся на те же объекты

Создание новой переменной не создаёт новый объект — она лишь создаёт дополнительную ссылку на существующий объект. Пример такой ошибки:

function updateNestedState(state, action) {
let nestedState = state.nestedState
// ERROR: this directly modifies the existing object reference - don't do this!
nestedState.nestedField = action.data

return {
...state,
nestedState
}
}

Эта функция корректно возвращает поверхностную копию объекта верхнего уровня, но поскольку переменная nestedState всё ещё ссылается на существующий объект, состояние было изменено напрямую.

Типичная ошибка #2: Поверхностное копирование только одного уровня

Другая распространённая вариация этой ошибки:

function updateNestedState(state, action) {
// Problem: this only does a shallow copy!
let newState = { ...state }

// ERROR: nestedState is still the same object!
newState.nestedState.nestedField = action.data

return newState
}

Поверхностного копирования верхнего уровня недостаточно — объект nestedState также должен быть скопирован.

Добавление и удаление элементов в массивах

Обычно содержимое JavaScript-массивов изменяется с помощью мутирующих функций, таких как push, unshift и splice. Поскольку в редьюсерах мы не хотим напрямую мутировать состояние, эти функции обычно следует избегать. В результате операции вставки или удаления часто реализуют так:

function insertItem(array, action) {
return [
...array.slice(0, action.index),
action.item,
...array.slice(action.index)
]
}

function removeItem(array, action) {
return [...array.slice(0, action.index), ...array.slice(action.index + 1)]
}

Однако помните, что ключевой момент — не изменять оригинальную ссылку в памяти. Если мы сначала создаём копию, то можем безопасно изменять её. Это справедливо как для массивов, так и для объектов, но вложенные значения всё равно должны обновляться по тем же правилам.

Это означает, что функции вставки и удаления можно реализовать и так:

function insertItem(array, action) {
let newArray = array.slice()
newArray.splice(action.index, 0, action.item)
return newArray
}

function removeItem(array, action) {
let newArray = array.slice()
newArray.splice(action.index, 1)
return newArray
}

Функцию удаления также можно реализовать как:

function removeItem(array, action) {
return array.filter((item, index) => index !== action.index)
}

Обновление элемента в массиве

Обновление одного элемента массива можно выполнить с помощью Array.map, возвращая новое значение для изменяемого элемента и существующие значения для остальных:

function updateObjectInArray(array, action) {
return array.map((item, index) => {
if (index !== action.index) {
// This isn't the item we care about - keep it as-is
return item
}

// Otherwise, this is the one we want - return an updated value
return {
...item,
...action.item
}
})
}

Библиотеки для неизменяемых обновлений

Поскольку написание кода для неизменяемых обновлений может быть утомительным, существует несколько библиотек, которые абстрагируют этот процесс. Их API и использование различаются, но все они стремятся предоставить более краткий и лаконичный способ записи обновлений. Например, Immer упрощает неизменяемые обновления до обычной функции с простыми JavaScript-объектами:

var usersState = [{ name: 'John Doe', address: { city: 'London' } }]
var newState = immer.produce(usersState, draftState => {
draftState[0].name = 'Jon Doe'
draftState[0].address.city = 'Paris'
//nested update similar to mutable way
})

Некоторые библиотеки, например dot-prop-immutable, используют строковые пути для команд:

state = dotProp.set(state, `todos.${index}.complete`, true)

Другие библиотеки, такие как immutability-helper (форк устаревшего аддона React Immutability Helpers), используют вложенные значения и вспомогательные функции:

var collection = [1, 2, { a: [12, 17, 15] }]
var newCollection = update(collection, {
2: { a: { $splice: [[1, 1, 13, 14]] } }
})

Они могут служить полезной альтернативой ручному написанию логики неизменяемых обновлений.

Список многих утилит для неизменяемых обновлений можно найти в разделе Immutable Data#Immutable Update Utilities каталога Redux Addons Catalog.

Упрощение неизменяемых обновлений с Redux Toolkit

Наш пакет Redux Toolkit включает утилиту createReducer, которая внутри использует Immer. Благодаря этому вы можете писать редюсеры, которые как будто "мутируют" состояние, но обновления на самом деле применяются неизменяемым образом.

Это позволяет писать логику неизменяемых обновлений гораздо проще. Вот как может выглядеть пример с вложенными данными с использованием createReducer:

import { createReducer } from '@reduxjs/toolkit'

const initialState = {
first: {
second: {
id1: { fourth: 'a' },
id2: { fourth: 'b' }
}
}
}

const reducer = createReducer(initialState, {
UPDATE_ITEM: (state, action) => {
state.first.second[action.someId].fourth = action.someValue
}
})

Это явно гораздо короче и легче для чтения. Однако это работает корректно только при использовании "волшебной" функции createReducer из Redux Toolkit, которая оборачивает редюсер в функцию Immer produce. Если использовать этот редюсер без Immer, он действительно будет мутировать состояние! Также по коду неочевидно, что эта функция безопасна и обновляет состояние неизменяемо. Убедитесь, что полностью понимаете концепции неизменяемых обновлений. При использовании этого подхода добавьте комментарии, поясняющие, что ваши редюсеры используют Redux Toolkit и Immer.

Кроме того, утилита createSlice из Redux Toolkit автоматически генерирует создателей действий и типы действий на основе предоставленных функций-редюсеров, с такими же возможностями обновления на базе Immer.

Дополнительные материалы