TL;DR: классы можно использовать как четко определенные части неизменного состояния, при этом операции применяются к состоянию, выраженному в виде методов. Эти классы можно использовать для инкапсуляции состояния в вашем приложении React и красивой компоновки.

Я хочу представить вам шаблон, который я использовал для обработки состояния и, что более важно, изменений состояния в приложениях React. Для демонстрации я буду использовать канонический пример — список задач.

Мы собираемся начать с малого и провести рефакторинг по шаблону, который я называю: «Неизменяемый класс состояния». Пойдем!

class TodoList extends React.Component {
  state = {
    todos: []
  }
  render () {
    return (
      <ul>
        {this.state.todos.map(todo => (
          <li key={todo.id}>
            {todo.title}
            {todo.isCompleted ? (
              <span>Completed!</span>
            ) : null}
          </li>
        )}
      </ul>
    )
  }
}

Мы начинаем с пустого списка задач. В нашем методе рендеринга мы видим, что каждый элемент списка задач должен иметь id, название и логическое поле isCompleted.

Итак, давайте добавим очень простой способ добавить todo. Это наше первое изменение состояния.

let ID = 0
class TodoList extends React.Component {
  state = {
    todos: []
  }
  newTodoTitleInput = React.createRef()
  addTodo = () => {  
    const input = this.newTodoTitleInput.current
    const todo = {
      id: ++ID,
      title: input.value,
      isCompleted: false
    }
    this.setState(state => ({
      todos: [...state.todos, todo]
    })
    input.value = ''
  }
  render () {
    return (
      <>
        <ul>
          {this.state.todos.map(todo => (
            <li key={todo.id}>
              {todo.title}
              {todo.isCompleted ? (
                <span>Completed!</span>
              ) : null}
            </li>
          )}
        </ul>
        <input ref={this.newTodoTitleInput} />
        <button onClick={this.addTodo}>Add Todo</button>
      </>
    )
  }
}

Конечно, мы можем сделать ввод контролируемым вместо использования ссылки, но сейчас это не важно. Важно вот что: мы достигаем самого простого способа обработки состояния в React: установки нового состояния в методе класса компонента, запускаемого прослушивателем событий, зарегистрированным в методе рендеринга.

Далее мы хотим иметь возможность помечать задачи как выполненные.

let ID = 0
class TodoList extends React.Component {
  state = {
    todos: []
  }
  newTodoTitleInput = React.createRef()
  addTodo = () => {  
    const input = this.newTodoTitleInput.current
    const todo = {
      id: ++ID,
      title: input.value,
      isCompleted: false
    }
    this.setState(state => ({
      todos: [...state.todos, todo]
    })
    input.value = ''
  }
  toggleTodo = todo => () => {
    this.setState(state => ({
      todos: state.todos.map(t =>
        t.id === todo.id
          ? { ...t, isCompleted: !t.isCompleted }
          : t
      )
    })
  }
  render () {
    return (
      <>
        <ul>
          {this.state.todos.map(todo => (
            <li key={todo.id}>
              {todo.title}
              <input
                type='checkbox'
                checked={todo.isCompleted}
                onChange={this.toggleTodo(todo)}
              />
            </li>
          )}
        </ul>
        <input ref={this.newTodoTitleInput} />
        <button onClick={this.addTodo}>Add Todo</button>
      </>
    )
  }
}

Итак, на данный момент все не так уж и слишком плохо, но компонент начинает загрязняться, и дальше будет только хуже. Итак, мы сделаем шаг назад и подумаем, что на самом деле делает этот компонент:

  • Есть список задач, представленный в виде неупорядоченного списка.
  • Также есть ввод текста и кнопка. Когда кнопка нажата, значение ввода текста используется для создания нового элемента списка задач.
  • Рядом с каждым элементом есть флажок, указывающий, отмечен ли элемент или нет. Установка флажка переключает состояние завершения элемента.

Теперь о рефакторинге. Мы начнем с создания класса, переместив это начальное состояние в конструктор:

class TodoListState {
  constructor (todos = []) {
    this.todos = todos
  }
}

Чтобы добавить задачу, требуется название новой задачи. Поскольку мы хотим сохранить неизменность, нам нужно будет вернуть новый экземпляр класса с добавленным новым заданием.

let ID = 0
class TodoListState {
  constructor (todos = []) {
    this.todos = todos
  }
  addTodo (title) {
    const todo = {
      id: ++ID,
      title,
      isCompleted: false
    }
    return new TodoListState([...this.todos, todo])
  }
}

Довольно прямолинейно! Давайте сделаем то же самое с методом toggleTodo.

let ID = 0
class TodoListState {
  constructor (todos = []) {
    this.todos = todos
  }
  addTodo (title) {
    const todo = {
      id: ++ID,
      title,
      isCompleted: false
    }
    return new TodoListState([...this.todos, todo])
  }
  toggleTodo (todo) {
    return new TodoListState(
      this.todos.map(t =>
        t.id === todo.id
          ? { ...t, isCompleted: !t.isCompleted }
          : t
      )
    )
  }
}

Давайте посмотрим, что произойдет с нашим компонентом, когда мы интегрируем его с этим новым классом.

class TodoList extends React.Component {
  state = {
    listState: new TodoListState()
  }
  newTodoTitleInput = React.createRef()
  addTodo = () => {  
    const input = this.newTodoTitleInput.current
    this.setState(state => ({
      listState: state.listState.addTodo(input.value)
    })
    input.value = ''
  }
  toggleTodo = todo => () => {
    this.setState(state => ({
      listState: state.listState.toggleTodo(todo)
    })
  }
  render () {
    return (
      <>
        <ul>
          {this.state.listState.todos.map(todo => (
            <li key={todo.id}>
              {todo.title}
              <input
                type='checkbox'
                checked={todo.isCompleted}
                onChange={this.toggleTodo(todo)}
              />
            </li>
          )}
        </ul>
        <input ref={this.newTodoTitleInput} />
        <button onClick={this.addTodo}>Add Todo</button>
      </>
    )
  }
}

Довольно круто, правда? Теперь давайте выполним еще один распространенный рефакторинг: извлечение состояния вверх и вниз из компонента. Мы заменим состояние обычными реквизитами value и onChange:

class TodoList extends React.Component {
  static propTypes = {
    value: PropTypes.instanceOf(TodoListState).isRequired,
    onChange: PropTypes.func.isRequired,
  }
  newTodoTitleInput = React.createRef()
  addTodo = () => {  
    const input = this.newTodoTitleInput.current
    this.props.onChange(
      this.props.value.addTodo(input.value)
    )
    input.value = ''
  }
  toggleTodo = todo => () => {
    this.props.onChange(
      this.props.value.toggleTodo(todo)
    )
  }
  render () {
    return (
      <>
        <ul>
          {this.props.value.todos.map(todo => (
            <li key={todo.id}>
              {todo.title}
              <input
                type='checkbox'
                checked={todo.isCompleted}
                onChange={this.toggleTodo(todo)}
              />
            </li>
          )}
        </ul>
        <input ref={this.newTodoTitleInput} />
        <button onClick={this.addTodo}>Add Todo</button>
      </>
    )
  }
}

Верно, есть способ указать экземпляры класса в типах свойств! Готово!

Так как же масштабируется этот паттерн, спросите вы? Не изменяя API класса TodoListState, мы можем извлечь состояние каждого отдельного элемента списка дел в отдельный класс:

class TodoState {
  constructor ({ id, title, isCompleted = false }) {
    this.id = id
    this.title = title
    this.isCompleted = isCompleted
  }
  toggle () {
    return new TodoState({
      id: this.id,
      title: this.title,
      isCompleted: !this.isCompleted
    })
  }
}

Наш TodoListState теперь выглядит так:

let ID = 0
class TodoListState {
  constructor (todos = []) {
    this.todos = todos
  }
  addTodo (title) {
    const todo = new TodoState({
      id: ++ID,
      title
    })
    return new TodoListState([...this.todos, todo])
  }
  toggleTodo (todo) {
    return new TodoListState(
      this.todos.map(t =>
        t.id === todo.id
          ? t.toggle()
          : t
      )
    )
  }
}

Поскольку все классы состояний неизменяемы, они составляются вместе. Изменение задачи означает получение нового состояния. Изменение его в списке задач означает получение нового списка.

Попробуйте этот шаблон и посмотрите, что вы думаете. Библиотеки для установки не требуется, и она не конфликтует с существующими способами управления состоянием. На самом деле этот шаблон используется в DraftJS, где компонент Editor использует экземпляр EditorState в качестве своего значения, открывая способы преобразования состояние текстового редактора с помощью методов.