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

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



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

В подобных ситуациях можно воспользоваться библиотекой Immer. Её использованию в 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 {produce} = 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?

  • +23
  • 3,3k
  • 5
RUVDS.com
1 072,98
RUVDS – хостинг VDS/VPS серверов
Поделиться публикацией

Комментарии 5

    0
    А чем MobX не угодил?
    Шустрый, удобный, простой в использовании. Нет кучи хуков, калбеков и прочего.
      +2
      Это ядерная бомба против рогатки. Из статьи не вполне ясно читается, но Immer — это штука для уменьшения шума в виде клонирования объектов туда-сюда каждый раз при изменении стейта. Это всё, что он делает. По сути очень мелкая и простая библиотека для посыпания работы со стейтом тонким слоем сахара.

      MobX же — это вообще «выкиньте этот ваш ванильный реактовый стейт и работайте в парадигме реактивного программирования».
      0
      Как-то не правильно в первом примере используется produce, по-моему.
        0
        Пару недель как пишу на Реакте, основная специализация — бэк, поэтому пара элементарных вопросов:

        1. В этом примере:

        this.setState({ list: [...this.state.list, newItem] })

        правильно ли, что, если необходимо не добавить новый элемент, а изменить свойство существующего, то Spread уже не спасет и нужно клонировать элементы массива? Делает ли Immer это за нас, можно ли написать:

        draft.list[0].name = 'smth';


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

        this.setState(
            produce(draft => {
                draft.list = draft.list.concat(newItem)
            })
        
          0
          Как я понял из документации, если мы обновляем стейт с использованием текущего состояния, нужно использовать перегрузку setState, принимающую функцию [...]

          Ну так produce же функцию и возвращает.

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

        Самое читаемое