Выбор структуры состояния
Правильная организация состояния может стать решающим фактором, определяющим, будет ли компонент удобен для модификации и отладки или станет постоянным источником ошибок. Вот несколько советов, которые стоит учитывать при организации состояния.
Вы узнаете
- Когда использовать одну переменную состояния, а когда — несколько
- Чего следует избегать при организации состояния
- Как исправить типичные проблемы со структурой состояния
Принципы структурирования состояния
При написании компонента, хранящего некоторое состояние, вам придётся решать, сколько переменных состояния использовать и каким должен быть формат их данных. Хотя написать корректную программу можно даже с неоптимальной структурой состояния, существует несколько принципов, которые помогут вам сделать более правильный выбор:
- Группируйте связанные состояния. Если вы всегда обновляете две или более переменных состояния одновременно, подумайте о том, чтобы объединить их в одну переменную состояния.
- Избегайте противоречий в состоянии. Когда состояние структурировано таким образом, что несколько его частей могут противоречить друг другу и «не согласовываться», вы оставляете место для ошибок. Старайтесь этого избегать.
- Избегайте избыточного состояния. Если вы можете вычислить какую-то информацию из пропсов компонента или его существующих переменных состояния во время рендеринга, вам не следует помещать эту информацию в состояние данного компонента.
- Избегайте дублирования в состоянии. Когда одни и те же данные дублируются между несколькими переменными состояния или внутри вложенных объектов, их сложно синхронизировать. Сокращайте дублирование, когда это возможно.
- Избегайте глубоко вложенного состояния. Глубоко иерархическое состояние не очень удобно обновлять. По возможности предпочитайте плоскую структуру состояния.
Цель этих принципов — упростить обновление состояния без введения ошибок. Удаление избыточных и дублирующихся данных из состояния помогает обеспечить синхронизацию всех его частей. Это похоже на то, как инженер по базам данных может захотеть «нормализовать» структуру базы данных, чтобы снизить вероятность появления ошибок. Перефразируя Альберта Эйнштейна: «Сделайте ваше состояние настолько простым, насколько это возможно, но не проще».
Теперь давайте посмотрим, как эти принципы применяются на практике.
Группируйте связанный state
Иногда вы можете сомневаться, использовать одну переменную state или несколько.
Стоит ли делать так?
const [x, setX] = useState(0);
const [y, setY] = useState(0);Или так?
const [position, setPosition] = useState({ x: 0, y: 0 });С технической точки зрения можно использовать любой из этих подходов. Но если две переменные state всегда изменяются вместе, хорошей идеей может быть объединить их в одну переменную state. Тогда вы не забудете постоянно поддерживать их в синхронизации, как в этом примере, где движение курсора обновляет обе координаты красной точки:
import { useState } from 'react';export default function MovingDot() { const [position, setPosition] = useState({ x: 0, y: 0 }); return ( <div onPointerMove={e => { setPosition({ x: e.clientX, y: e.clientY }); }} style={{ position: 'relative', width: '100vw', height: '100vh', }}> <div style={{ position: 'absolute', backgroundColor: 'red', borderRadius: '50%', transform: \`translate(\${position.x}px, \${position.y}px)\`, left: -10, top: -10, width: 20, height: 20, }} /> </div> )}Другой случай, когда данные лучше хранить в объекте или массиве, — это когда вы не знаете заранее, сколько именно частей state вам понадобится. Например, это полезно для формы, в которой пользователь может добавлять собственные поля.
Если переменная state — это объект, помните, что нельзя обновить только одно его поле, не скопировав явно остальные поля. Например, в приведённом выше примере нельзя сделать setPosition({ x: 100 }), потому что тогда свойства y вообще не будет! Вместо этого, если вы хотите изменить только x, нужно либо написать setPosition({ ...position, x: 100 }), либо разделить данные на две переменные state и вызвать setX(100).
Избегайте противоречий в state
Вот форма обратной связи для отеля с переменными state isSending и isSent:
import { useState } from 'react';export default function FeedbackForm() { const [text, setText] = useState(''); const [isSending, setIsSending] = useState(false); const [isSent, setIsSent] = useState(false); async function handleSubmit(e) { e.preventDefault(); setIsSending(true); await sendMessage(text); setIsSending(false); setIsSent(true); } if (isSent) { return <h1>Thanks for feedback!</h1> } return ( <form onSubmit={handleSubmit}> <p>How was your stay at The Prancing Pony?</p> <textarea disabled={isSending} value={text} onChange={e => setText(e.target.value)} /> <br /> <button disabled={isSending} type="submit" > Send </button> {isSending && <p>Sending...</p>} </form> );}// Pretend to send a message.function sendMessage(text) { return new Promise(resolve => { setTimeout(resolve, 2000); });}Хотя этот код работает, он оставляет возможность для “невозможных” состояний. Например, если вы забудете вызвать setIsSent и setIsSending вместе, можно оказаться в ситуации, когда и isSending, и isSent одновременно равны true. Чем сложнее компонент, тем труднее понять, что именно произошло.
Поскольку isSending и isSent никогда не должны быть одновременно true, лучше заменить их одной переменной state status, которая может принимать одно из трёх допустимых значений: 'typing' (начальное), 'sending' и 'sent':
import { useState } from 'react';export default function FeedbackForm() { const [text, setText] = useState(''); const [status, setStatus] = useState('typing'); async function handleSubmit(e) { e.preventDefault(); setStatus('sending'); await sendMessage(text); setStatus('sent'); } const isSending = status === 'sending'; const isSent = status === 'sent'; if (isSent) { return <h1>Thanks for feedback!</h1> } return ( <form onSubmit={handleSubmit}> <p>How was your stay at The Prancing Pony?</p> <textarea disabled={isSending} value={text} onChange={e => setText(e.target.value)} /> <br /> <button disabled={isSending} type="submit" > Send </button> {isSending && <p>Sending...</p>} </form> );}// Pretend to send a message.function sendMessage(text) { return new Promise(resolve => { setTimeout(resolve, 2000); });}Для удобства чтения вы всё ещё можете объявить некоторые константы:
const isSending = status === 'sending';
const isSent = status === 'sent';Но это не переменные state, поэтому вам не нужно беспокоиться о том, что они разъедутся между собой.
Избегайте избыточного state
Если какую-то информацию можно вычислить во время рендера из props компонента или его уже существующих переменных state, не следует помещать её в state этого компонента.
Например, возьмём эту форму. Она работает, но сможете ли вы найти в ней избыточный state?
import { useState } from 'react';export default function Form() { const [firstName, setFirstName] = useState(''); const [lastName, setLastName] = useState(''); const [fullName, setFullName] = useState(''); function handleFirstNameChange(e) { setFirstName(e.target.value); setFullName(e.target.value + ' ' + lastName); } function handleLastNameChange(e) { setLastName(e.target.value); setFullName(firstName + ' ' + e.target.value); } return ( <> <h2>Let’s check you in</h2> <label> First name:{' '} <input value={firstName} onChange={handleFirstNameChange} /> </label> <label> Last name:{' '} <input value={lastName} onChange={handleLastNameChange} /> </label> <p> Your ticket will be issued to: <b>{fullName}</b> </p> </> );}В этой форме три переменные state: firstName, lastName и fullName. Однако fullName избыточен. Вы всегда можете вычислить fullName из firstName и lastName во время рендера, поэтому уберите его из state.
Вот как это можно сделать:
import { useState } from 'react';export default function Form() { const [firstName, setFirstName] = useState(''); const [lastName, setLastName] = useState(''); const fullName = firstName + ' ' + lastName; function handleFirstNameChange(e) { setFirstName(e.target.value); } function handleLastNameChange(e) { setLastName(e.target.value); } return ( <> <h2>Let’s check you in</h2> <label> First name:{' '} <input value={firstName} onChange={handleFirstNameChange} /> </label> <label> Last name:{' '} <input value={lastName} onChange={handleLastNameChange} /> </label> <p> Your ticket will be issued to: <b>{fullName}</b> </p> </> );}Здесь fullName — это не переменная state. Вместо этого она вычисляется во время рендера:
const fullName = firstName + ' ' + lastName;В результате обработчикам изменений не нужно делать ничего особенного, чтобы обновлять её. Когда вы вызываете setFirstName или setLastName, происходит повторный рендер, а затем fullName заново вычисляется из свежих данных.
Не зеркальте props в state
Распространённый пример избыточного state выглядит так:
function Message({ messageColor }) {
const [color, setColor] = useState(messageColor);Здесь переменная state color инициализируется значением prop messageColor. Проблема в том, что если родительский компонент позже передаст другое значение messageColor (например, 'red' вместо 'blue'), то переменная state color не обновится! State инициализируется только при первом рендере.
Именно поэтому “зеркалирование” prop в переменную state может приводить к путанице. Вместо этого используйте prop messageColor напрямую в коде. Если хотите дать ему более короткое имя, используйте константу:
function Message({ messageColor }) {
const color = messageColor;Так он не будет рассинхронизироваться с prop, переданным от родительского компонента.
“Зеркалирование” props в state имеет смысл только тогда, когда вы хотите игнорировать все изменения конкретного prop. По соглашению в таком случае начинайте имя prop с initial или default, чтобы было понятно, что его новые значения игнорируются:
function Message({ initialColor }) {
// Переменная state `color` хранит *первое* значение `initialColor`.
// Последующие изменения prop `initialColor` игнорируются.
const [color, setColor] = useState(initialColor);Избегайте дублирования в state
Этот компонент списка меню позволяет выбрать один дорожный перекус из нескольких:
import { useState } from 'react';const initialItems = [ { title: 'pretzels', id: 0 }, { title: 'crispy seaweed', id: 1 }, { title: 'granola bar', id: 2 },];export default function Menu() { const [items, setItems] = useState(initialItems); const [selectedItem, setSelectedItem] = useState( items[0] ); return ( <> <h2>What's your travel snack?</h2> <ul> {items.map(item => ( <li key={item.id}> {item.title} {' '} <button onClick={() => { setSelectedItem(item); }}>Choose</button> </li> ))} </ul> <p>You picked {selectedItem.title}.</p> </> );}Сейчас выбранный элемент хранится как объект в переменной state selectedItem. Однако это не очень удачно: содержимое selectedItem — это тот же самый объект, что и один из элементов массива items. То есть информация об элементе дублируется в двух местах.
Почему это проблема? Давайте сделаем каждый элемент редактируемым:
import { useState } from 'react';const initialItems = [ { title: 'pretzels', id: 0 }, { title: 'crispy seaweed', id: 1 }, { title: 'granola bar', id: 2 },];export default function Menu() { const [items, setItems] = useState(initialItems); const [selectedItem, setSelectedItem] = useState( items[0] ); function handleItemChange(id, e) { setItems(items.map(item => { if (item.id === id) { return { ...item, title: e.target.value, }; } else { return item; } })); } return ( <> <h2>What's your travel snack?</h2> <ul> {items.map((item, index) => ( <li key={item.id}> <input value={item.title} onChange={e => { handleItemChange(item.id, e) }} /> {' '} <button onClick={() => { setSelectedItem(item); }}>Choose</button> </li> ))} </ul> <p>You picked {selectedItem.title}.</p> </> );}Обратите внимание: если сначала нажать “Choose” у элемента, а потом отредактировать его, поле ввода обновится, но надпись внизу не отразит изменения. Это происходит потому, что у вас дублируется state, а selectedItem вы забыли обновить.
Хотя можно было бы обновлять и selectedItem, более простой способ — убрать дублирование. В этом примере вместо объекта selectedItem (который дублирует объект внутри items) вы храните в state selectedId, а затем получаете selectedItem, выполняя поиск в массиве items по этому ID:
import { useState } from 'react';const initialItems = [ { title: 'pretzels', id: 0 }, { title: 'crispy seaweed', id: 1 }, { title: 'granola bar', id: 2 },];export default function Menu() { const [items, setItems] = useState(initialItems); const [selectedId, setSelectedId] = useState(0); const selectedItem = items.find(item => item.id === selectedId ); function handleItemChange(id, e) { setItems(items.map(item => { if (item.id === id) { return { ...item, title: e.target.value, }; } else { return item; } })); } return ( <> <h2>What's your travel snack?</h2> <ul> {items.map((item, index) => ( <li key={item.id}> <input value={item.title} onChange={e => { handleItemChange(item.id, e) }} /> {' '} <button onClick={() => { setSelectedId(item.id); }}>Choose</button> </li> ))} </ul> <p>You picked {selectedItem.title}.</p> </> );}Раньше state дублировался так:
items = [{ id: 0, title: 'pretzels'}, ...]selectedItem = {id: 0, title: 'pretzels'}
После изменения это выглядит так:
items = [{ id: 0, title: 'pretzels'}, ...]selectedId = 0
Дублирование исчезло, и вы храните только действительно необходимый state!
Теперь, если вы редактируете выбранный элемент, сообщение ниже обновится сразу. Это происходит потому, что setItems вызывает повторный рендер, а items.find(...) находит элемент с обновлённым названием. Вам не нужно было хранить выбранный элемент в state, потому что важен только выбранный ID. Всё остальное можно вычислить во время рендера.
Избегайте слишком глубоко вложенного state
Представьте план путешествия, состоящий из планет, континентов и стран. Может возникнуть соблазн структурировать его state с помощью вложенных объектов и массивов, как в этом примере:
import { useState } from 'react';import { initialTravelPlan } from './places.js';function PlaceTree({ place }) { const childPlaces = place.childPlaces; return ( <li> {place.title} {childPlaces.length > 0 && ( <ol> {childPlaces.map(place => ( <PlaceTree key={place.id} place={place} /> ))} </ol> )} </li> );}export default function TravelPlan() { const [plan, setPlan] = useState(initialTravelPlan); const planets = plan.childPlaces; return ( <> <h2>Places to visit</h2> <ol> {planets.map(place => ( <PlaceTree key={place.id} place={place} /> ))} </ol> </> );}Теперь предположим, что вы хотите добавить кнопку для удаления уже посещённого места. Как бы вы это сделали? Обновление вложенного state требует создания копий объектов вплоть до изменённой части. Удаление глубоко вложенного места потребует копирования всей цепочки его родительских объектов. Такой код может быть очень громоздким.
Если state слишком вложенный и его сложно обновлять, подумайте о том, чтобы сделать его “плоским”. Вот один из способов перестроить эти данные. Вместо древовидной структуры, где у каждого place есть массив его дочерних мест, можно хранить у каждого места массив ID его дочерних мест. Затем сохраните отображение от каждого ID места к соответствующему месту.
Эта перестройка данных может напомнить вам таблицу базы данных:
import { useState } from 'react';
import { initialTravelPlan } from './places.js';
function PlaceTree({ id, placesById }) {
const place = placesById[id];
const childIds = place.childIds;
return (
<li>
{place.title}
{childIds.length > 0 && (
<ol>
{childIds.map(childId => (
<PlaceTree
key={childId}
id={childId}
placesById={placesById}
/>
))}
</ol>
)}
</li>
);
}
export default function TravelPlan() {
const [plan, setPlan] = useState(initialTravelPlan);
const root = plan[0];
const planetIds = root.childIds;
return (
<>
<h2>Places to visit</h2>
<ol>
{planetIds.map(id => (
<PlaceTree
key={id}
id={id}
placesById={plan}
/>
))}
</ol>
</>
);
}Теперь, когда state стал “плоским” (также известным как “нормализованным”), обновлять вложенные элементы стало проще.
Чтобы удалить место, теперь нужно обновить только два уровня state:
- Обновлённая версия его родительского места должна исключить удалённый ID из массива
childIds. - Обновлённая версия корневого объекта “таблицы” должна включать обновлённую версию родительского места.
Вот пример того, как это можно сделать:
import { useState } from 'react';
import { initialTravelPlan } from './places.js';
export default function TravelPlan() {
const [plan, setPlan] = useState(initialTravelPlan);
function handleComplete(parentId, childId) {
const parent = plan[parentId];
// Create a new version of the parent place
// that doesn't include this child ID.
const nextParent = {
...parent,
childIds: parent.childIds
.filter(id => id !== childId)
};
// Update the root state object...
setPlan({
...plan,
// ...so that it has the updated parent.
[parentId]: nextParent
});
}
const root = plan[0];
const planetIds = root.childIds;
return (
<>
<h2>Places to visit</h2>
<ol>
{planetIds.map(id => (
<PlaceTree
key={id}
id={id}
parentId={0}
placesById={plan}
onComplete={handleComplete}
/>
))}
</ol>
</>
);
}
function PlaceTree({ id, parentId, placesById, onComplete }) {
const place = placesById[id];
const childIds = place.childIds;
return (
<li>
{place.title}
<button onClick={() => {
onComplete(parentId, id);
}}>
Complete
</button>
{childIds.length > 0 &&
<ol>
{childIds.map(childId => (
<PlaceTree
key={childId}
id={childId}
parentId={id}
placesById={placesById}
onComplete={onComplete}
/>
))}
</ol>
}
</li>
);
}Вы можете делать state настолько вложенным, насколько вам нужно, но его “уплощение” может решить множество проблем. Это облегчает обновление state и помогает избежать дублирования в разных частях вложенного объекта.
Улучшение использования памяти
В идеале, чтобы улучшить использование памяти, вы также должны удалять удалённые элементы (и их дочерние элементы!) из объекта “таблицы”. Эта версия именно так и делает. Она также использует Immer, чтобы сделать логику обновления короче.
import { useImmer } from 'use-immer';
import { initialTravelPlan } from './places.js';
export default function TravelPlan() {
const [plan, updatePlan] = useImmer(initialTravelPlan);
function handleComplete(parentId, childId) {
updatePlan(draft => {
// Remove from the parent place's child IDs.
const parent = draft[parentId];
parent.childIds = parent.childIds
.filter(id => id !== childId);
// Forget this place and all its subtree.
deleteAllChildren(childId);
function deleteAllChildren(id) {
const place = draft[id];
place.childIds.forEach(deleteAllChildren);
delete draft[id];
}
});
}
const root = plan[0];
const planetIds = root.childIds;
return (
<>
<h2>Places to visit</h2>
<ol>
{planetIds.map(id => (
<PlaceTree
key={id}
id={id}
parentId={0}
placesById={plan}
onComplete={handleComplete}
/>
))}
</ol>
</>
);
}
function PlaceTree({ id, parentId, placesById, onComplete }) {
const place = placesById[id];
const childIds = place.childIds;
return (
<li>
{place.title}
<button onClick={() => {
onComplete(parentId, id);
}}>
Complete
</button>
{childIds.length > 0 &&
<ol>
{childIds.map(childId => (
<PlaceTree
key={childId}
id={childId}
parentId={id}
placesById={placesById}
onComplete={onComplete}
/>
))}
</ol>
}
</li>
);
}Иногда можно также уменьшить вложенность state, перенесев часть вложенного state в дочерние компоненты. Это хорошо работает для временного UI state, который не нужно хранить, например для состояния hover у элемента.
Вывод
- Если две переменные state всегда обновляются вместе, подумайте о том, чтобы объединить их в одну.
- Выбирайте переменные state внимательно, чтобы не создавать “невозможные” состояния.
- Структурируйте state так, чтобы снизить вероятность ошибки при его обновлении.
- Избегайте избыточного и дублирующегося state, чтобы не приходилось синхронизировать его вручную.
- Не помещайте props в state, если только вы специально не хотите запретить их обновление.
- Для UI-паттернов вроде выбора храните в state ID или индекс, а не сам объект.
- Если обновлять глубоко вложенный state сложно, попробуйте сделать его плоским.
Реагирование на ввод с помощью состояний
React предоставляет декларативный подход к управлению пользовательским интерфейсом. Вместо того чтобы напрямую манипулировать отдельными элементами интерфейса, вы описываете различные состояния, в которых может находиться ваш компонент, и переключаетесь между ними в ответ на действия пользователя. Это похоже на то, как дизайнеры подходят к созданию пользовательского интерфейса.
Совместное использование state между компонентами
Иногда вам нужно, чтобы state двух компонентов всегда изменялся вместе. Для этого уберите state из обоих компонентов, перенесите его в ближайший общий родительский компонент, а затем передайте его вниз через props. Это называется *поднятием state*, и это одна из самых распространённых вещей, которые вы будете делать при написании кода на React.