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 в качестве своего значения, открывая способы преобразования состояние текстового редактора с помощью методов.