Хабрахабр

[Перевод] Использование Immer для управления состоянием React-приложений

Состояние используется для организации наблюдения за данными React-приложений. Состояния меняются по мере того, как пользователи взаимодействуют с приложениями. Когда пользователь выполняет некое действие — нам нужно обновить состояние, представляющее собой набор данных, на основе которых формируется то, что пользователь видит на экране. Обновляют состояния React-приложений с помощью метода setState.

А именно, программисту становится непросто ориентироваться в состоянии и пользоваться его данными в приложении.
В подобных ситуациях можно воспользоваться библиотекой Immer. Так как состояния не должны обновляться напрямую (в React состояние должно быть иммутабельным), при усложнении структуры состояний работа с ними превращается в весьма нетривиальную задачу. Её использованию в React-приложениях посвящён материал, перевод которого мы сегодня публикуем.

Основы использования Immer в React-приложениях

При использовании Immer структура состояния React-приложения может быть упрощена, а это значит, что с ним легче будет работать. В Immer используется концепция так называемых «черновиков» («draft»). «Черновик» можно воспринимать как копию состояния, но не само состояние.

Изменение данных, входящих в состояние, делается в «черновике», после чего, на основе изменений, внесённых в «черновик», выполняется обновление текущего состояния приложения. Immer как бы копирует состояние, «нажимая» клавиши CMD+C, потом, клавишами CMD+V, вставляет то, что скопировал, в такое место, где скопированные данные можно спокойно посмотреть, не затрагивая оригинальные материалы.

Предположим, состояние вашего приложения выглядит так:

this.state = { name: 'Kunle', age: 30, city: 'Lagos', country: 'Nigeria'
}

Здесь представлены данные о пользователе. Этот пользователь, как оказалось, празднует 31-й день рождения. Это означает, что нам нужно обновить его возраст (свойство age). Если для решения этой задачи использовать Immer, то сначала будет создана копия этого состояния.

Это означает, что теперь существуют две копии состояния. Теперь представьте себе, что сделана копия состояния, её отдали курьеру, а тот доставил эту копию пользователю Kunle. Пользователь, редактируя «черновик», меняет свой возраст на 31. Одна из них представляет собой текущее состояние приложения, а вторая — это «черновая» копия, переданная пользователю. Там выполняется сравнение двух версий документа и в текущее состояние приложения вносятся лишь изменения, касающиеся возраста пользователя, так как ничего больше в «черновике» не менялось. После этого курьер возвращается с изменённым документом и отдаёт «черновик» приложению.

В целом же можно сказать, что использование Immer просто способствует повышению удобства работы с иммутабельным состоянием. Такая схема работы не нарушает идеи иммутабельности состояния — текущее состояние напрямую не обновляется.

Пример №1: светофор

Давайте взглянем на пример рабочего приложения, в котором используется Immer. Предположим, вы занимаетесь разработкой приложения-светофора. В этом приложении вы можете попробовать воспользоваться Immer.

Вот как выглядит экран этого приложения в один из моментов его работы.

Приложение-светофор

Здесь можно ознакомиться с кодом проекта.

Вот как будет выглядеть компонент, учитывая то, что в проекте используется Immer.

const = immer class App extends React.Component { state = { red: 'red', yellow: 'black', green: 'black', next: "yellow" } componentDidMount() { this.interval = setInterval(() => this.changeHandle(), 3000); } componentWillUnmount() { clearInterval(this.interval); } handleRedLight = () => { this.setState( produce(draft => { draft.red = 'red'; draft.yellow = 'black'; draft.green = 'black'; draft.next = 'yellow' }) ) } handleYellowLight = () => { this.setState( produce(draft => { draft.red = 'black'; draft.yellow = 'yellow'; draft.green = 'black'; draft.next = 'green' }) ) } handleGreenLight = () => { this.setState( produce(draft => { draft.red = 'black'; draft.yellow = 'black'; draft.green = 'green'; draft.next = 'red' }) ) } changeHandle = () => { if (this.state.next === 'yellow') { this.handleYellowLight() } else if (this.state.next === 'green') { this.handleGreenLight() } else { this.handleRedLight() } } render() { return ( <div className="box"> <div className="circle" style={{backgroundColor: this.state.red}}></div> <div className="circle" style={{backgroundColor: this.state.yellow}}></div> <div className="circle" style={{backgroundColor: this.state.green}}></div> </div> );
}
};

Produce — это стандартная функция, импортируемая из Immer. Её мы передаём, в качестве значения, методу setState(). Функция produce принимает функцию, которая, в качестве аргумента, принимает draft. Именно внутри этой функции мы можем отредактировать «черновик» состояния, приведя его к такому виду, который должно принять реальное состояние.

Сначала создадим функцию: Если всё это кажется вам слишком сложным — вот ещё один подход к написанию кода, решающего те же задачи, что и вышеприведённый код.

const handleLight = (state) => { return produce(state, (draft) => { draft.red = 'black'; draft.yellow = 'black'; draft.green = 'green'; draft.next = 'red' });
}

Функции produce мы, в качестве аргументов, передаём текущее состояние приложения и другую функцию, которая принимает аргумент draft. Теперь воспользуемся всем этим в компоненте:

handleGreenLight = () => { const nextState = handleLight(this.state) this.setState(nextState)
}

Пример №2: список покупок

Если вы уже некоторое время работаете с React — тогда вас не удивить синтаксисом spread. При использовании Immer у вас нет необходимости пользоваться подобными конструкциями. В особенности — при работе с содержащимися в состоянии массивами.

Продолжим исследование возможностей Immer, создав приложение, реализующее список покупок.

Список покупок

Здесь можно с ним поэкспериментировать.

Вот компонент, с которым мы работаем.

class App extends React.Component { constructor(props) { super(props) this.state = { item: "", price: 0, list: [ { id: 1, name: "Cereals", price: 12 }, { id: 2, name: "Rice", price: 10 } ] } } handleInputChange = e => { this.setState( produce(draft => { draft[event.target.name] = event.target.value })) } handleSubmit = (e) => { e.preventDefault() const newItem = { id: uuid.v4(), name: this.state.name, price: this.state.price } this.setState( produce(draft => { draft.list = draft.list.concat(newItem) }) ) }; render() { return ( <React.Fragment> <section className="section"> <div className="box"> <form onSubmit={this.handleSubmit}> <h2>Create your shopping list</h2> <div> <input type="text" placeholder="Item's Name" onChange={this.handleInputChange} name="name" className="input" /> </div> <div> <input type="number" placeholder="Item's Price" onChange={this.handleInputChange} name="price" className="input" /> </div> <button className="button is-grey">Submit</button> </form> </div> <div className="box"> { this.state.list.length ? ( this.state.list.map(item => ( <ul> <li key={item.id}> <p>{item.name}</p> <p>${item.price}</p> </li> <hr /> </ul> )) ) : <p>Your list is empty</p> } </div> </section> </React.Fragment> ) }
} ReactDOM.render( <App />, document.getElementById('root')
);

При добавлении в список новых заметок о покупках нам нужно обновлять состояние компонента, в котором, в массиве list, должны сохраняться новые элементы. Для того чтобы обновить элемент состояния list с использованием метода setState() понадобится следующий код:

handleSubmit = (e) => { e.preventDefault() const newItem = { id: uuid.v4(), name: this.state.name, price: this.state.price } this.setState({ list: [...this.state.list, newItem] })
};

Если в ходе работы приложения нужно обновлять множество элементов состояния — синтаксисом spread придётся пользоваться очень часто. Новое состояние получают, комбинируя то, что уже есть в состоянии, с новыми данными. С ростом числа изменений усложняется и работа. Если же воспользоваться Immer — подобные вещи сложностей не вызывают. Вы можете убедиться в этом, проанализировав код примера, приведённый в начале этого раздела.

Например, подобное может понадобиться в том случае, если надо подсчитывать число записей в списке или общую стоимость всех запланированных покупок. А что если нам хочется добавить в проект функцию, которая, в виде коллбэка, будет вызываться после обновления состояния?

Ниже показан его интерфейс. Тут можно взглянуть на код приложения, которое мы сейчас будем анализировать.

Приложение с функцией подсчёта общей стоимости запланированных покупок

Начнём работу с создания механизма обновления состояния. Итак, предположим, что мы хотим вычислять общую стоимость запланированных покупок. Этот механизм представлен функцией handleSubmit:

handleSubmit = (e) => { e.preventDefault() const newItem = { id: uuid.v4(), name: this.state.name, price: this.state.price } this.setState( produce(draft => { draft.list = draft.list.concat(newItem) }), () => { this.calculateAmount(this.state.list) } )
};

В функции handleSubmit мы сначала создаём объект, основываясь на данных, введённых пользователем. Ссылку на объект записываем в константу newItem. Для формирования нового состояния приложения используется метод .concat(). Этот метод, вызванный на массиве, возвращает новый массив, в который входят элементы исходного массива, а также новый элемент. Новый массив записывается в draft.list. После этого Immer может обновить состояние приложения.

Важно обратить внимание на то, что в этой функции используется обновлённая версия состояния. Коллбэк, функция calculateAmount, вызывается после обновления состояния.

Функция calculateAmount будет выглядеть так:

calculateAmount = (list) => { let total = 0; for (let i = 0; i < list.length; i++) { total += parseInt(list[i].price, 10) } this.setState( produce(draft => { draft.totalAmount = total }) )
}

Хуки Immer

Use-immer — это хук, который позволяет разработчику управлять состоянием React-приложений. Посмотрим на то, как работает этот хук, реализовав на его основе классическое приложение-счётчик:

import React from "react";
import {useImmer} from "use-immer"; const Counter = () => { const [count, updateCounter] = useImmer({ value: 0 }); function increment() { updateCounter(draft => { draft.value = draft.value +1; }); } return ( <div> <h1> Counter {count.value} </h1> <br /> <button onClick={increment}>Increment</button> </div> );
} export default Counter;

Функция useImmer очень похожа на метод useState. Функция возвращает состояние и функцию, обновляющую состояние. Когда производится первая загрузка компонента, содержимое состояния (в данном случае — это свойство count) соответствует тому значению, которое передано useImmer. Использование возвращённой функции для обновления состояния позволяет нам создать функцию increment, увеличивающую значение свойства состояния count.

А вот код, в котором используется хук для Immer, напоминающий useReducer:

import React, { useRef } from "react";
import {useImmerReducer } from "use-immer";
import uuidv4 from "uuid/v4"
const initialState = [];
const reducer = (draft, action) => { switch (action.type) { case "ADD_ITEM": draft.push(action.item); return; case "CLEAR_LIST": return initialState; default: return draft; }
}
const Todo = () => { const inputEl = useRef(null); const [state, dispatch] = useImmerReducer(reducer, initialState); const handleSubmit = (e) => { e.preventDefault() const newItem = { id: uuidv4(), text: inputEl.current.value }; dispatch({ type: "ADD_ITEM", item: newItem }); inputEl.current.value = ""; inputEl.current.focus(); } const handleClear = () => { dispatch({ type: 'CLEAR_LIST' }) } return ( <div className='App'> <header className='App-header'> <ul> {state.map(todo => { return <li key={todo.id}>{todo.text}</li>; })} </ul> <form onSubmit={handleSubmit}> <input type='text' ref={inputEl} /> <button type='submit' > Add Todo </button> </form> <button onClick={handleClear} > Clear Todos </button> </header> </div> );
}
export default Todo;

Функция useImmerReducer принимает функцию-редьюсер и начальное состояние. Она возвращает состояние и функцию dispatch. После этого можно обойти состояние для вывода на экран имеющихся в нём элементов. Отправка действий, производимая с помощью функции dispatch, выполняется при добавлении в список дел нового элемента и при очистке списка. Отправляемому действию назначается тип, на основании которого в функции-редьюсере принимается решение о том, что именно нужно предпринять для обработки конкретного действия. 

Благодаря этому у нас имеется удобный способ управления состоянием приложения. В редьюсере мы используем, как и раньше, сущность draft, а не state.

Код, использованный в предыдущем примере, можно найти здесь.

Итоги

В этом материале мы поговорили об Immer — о библиотеке, которая упрощает управление состоянием React-приложений. Автор материала полагает, что все, кому эта библиотека интересна, вполне могут воспользоваться Immer в своих новых приложениях или неспешно внедрить её в один из текущих проектов.

Вот материал, в котором вы можете найти некоторые подробности об Immer.

Уважаемые читатели! Планируете ли вы пользоваться Immer?

Теги
Показать больше

Похожие статьи

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»
Закрыть