Escolhendo a Estrutura do Estado
Estruturar bem o estado pode fazer a diferença entre um componente que é agradável de modificar e depurar, e um que é uma fonte constante de erros. Aqui estão algumas dicas que você deve considerar ao estruturar estados.
Você aprenderá
- Quando usar uma única variável de estado versus várias
- O que evitar ao organizar estados
- Como corrigir problemas comuns na estrutura do estado
Princípios para estruturar estados
Quando você escreve um componente que mantém algum estado, você terá que fazer escolhas sobre quantas variáveis de estado usar e qual deve ser a forma dos dados. Embora seja possível escrever programas corretos mesmo com uma estrutura de estado subótima, existem alguns princípios que podem orientá-lo a fazer escolhas melhores:
- Agrupe estados relacionados. Se você sempre atualiza duas ou mais variáveis de estado ao mesmo tempo, considere uni-las em uma única variável de estado.
- Evite contradições no estado. Quando o estado é estruturado de forma que várias partes do estado possam se contradizer e “discordar” umas das outras, você deixa espaço para erros. Tente evitar isso.
- Evite estados redundantes. Se você puder calcular algumas informações das props do componente ou de suas variáveis de estado existentes durante a renderização, não coloque essas informações no estado desse componente.
- Evite duplicação no estado. Quando os mesmos dados são duplicados entre várias variáveis de estado, ou dentro de objetos aninhados, é difícil mantê-los sincronizados. Reduza a duplicação quando puder.
- Evite estados muito aninhados. Um estado muito hierárquico não é muito conveniente para atualizar. Quando possível, prefira estruturar o estado de forma plana.
O objetivo por trás destes princípios é tornar o estado fácil de atualizar sem introduzir erros. Remover dados redundantes e duplicados do estado ajuda a garantir que todas as suas partes permaneçam sincronizadas. Isso é semelhante a como um engenheiro de banco de dados pode querer “normalizar” a estrutura do banco de dados para reduzir a chance de erros. Parafraseando Albert Einstein, “Faça seu estado o mais simples possível - mas não simples demais.”
Agora vamos ver como estes princípios se aplicam na prática.
Agrupe estados relacionados
As vezes você pode ficar em dúvida entre usar uma única variável de estado, ou várias.
Você deveria fazer isto?
const [x, setX] = useState(0);
const [y, setY] = useState(0);
Ou isto?
const [position, setPosition] = useState({ x: 0, y: 0 });
Tecnicamente, você pode usar qualquer uma dessas abordagens. Mas se duas variáveis de estado sempre mudam juntas, pode ser uma boa ideia uní-las em uma única variável de estado. Assim você não esquecerá de sempre mantê-las sincronizadas, como neste exemplo onde mover o cursor atualiza ambas as coordenadas do ponto vermelho:
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> ) }
Outro caso em que você agrupará dados em um objeto ou em um array é quando você não sabe quantas variáveis de estado vai precisar. Por exemplo, é útil quando você tem um formulário onde o usuário pode adicionar campos personalizados.
Evite contradições no estado
Aqui está um formulário de feedback do hotel com as variáveis de estado isSending
e 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>Obrigado pelo feedback!</h1> } return ( <form onSubmit={handleSubmit}> <p>Como foi sua estadia no Pônei Saltitante?</p> <textarea disabled={isSending} value={text} onChange={e => setText(e.target.value)} /> <br /> <button disabled={isSending} type="submit" > Enviar </button> {isSending && <p>Enviando...</p>} </form> ) } // Simula o envio de uma mensagem. function sendMessage(text) { return new Promise(resolve => { setTimeout(resolve, 2000); }); }
Embora este código funcione, ele deixa a porta aberta para estados “impossíveis”. Por exemplo, se você esquecer de chamar setIsSent
e setIsSending
juntos, você pode acabar em uma situação onde tanto isSending
quanto isSent
são true
ao mesmo tempo. Quão mais complexo for o seu componente, mais difícil será entender o que aconteceu.
Como isSending
e isSent
nunca devem ser true
ao mesmo tempo, é melhor substituí-los por uma variável de estado status
que pode assumir um de três estados válidos: 'typing'
(inicial), 'sending'
e '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>Obrigado pelo feedback!</h1> } return ( <form onSubmit={handleSubmit}> <p>Como foi sua estadia no Pônei Saltitante?</p> <textarea disabled={isSending} value={text} onChange={e => setText(e.target.value)} /> <br /> <button disabled={isSending} type="submit" > Enviar </button> {isSending && <p>Enviando...</p>} </form> ); } // Simula o envio de uma mensagem. function sendMessage(text) { return new Promise(resolve => { setTimeout(resolve, 2000); }); }
Voce ainda pode declarar algumas constantes para legibilidade:
const isSending = status === 'sending';
const isSent = status === 'sent';
Mas elas não são variáveis de estado, então você não precisa se preocupar com elas ficando fora de sincronia uma com a outra.
Evite estados redundantes
Se você pode calcular algumas informações das props do componente ou de suas variáveis de estado existentes durante a renderização, você não deveria colocar essas informações no estado desse componente.
Por exemplo, neste formulário. Ele funciona, mas você consegue encontrar algum estado redundante nele?
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>Vamos fazer seu check-in</h2> <label> Primeiro nome:{' '} <input value={firstName} onChange={handleFirstNameChange} /> </label> <label> Sobrenome:{' '} <input value={lastName} onChange={handleLastNameChange} /> </label> <p> Seu ticket será emitido para: <b>{fullName}</b> </p> </> ) }
Este formulário tem três variáveis de estado: firstName
, lastName
e fullName
. No entanto, fullName
é redundante. Você sempre pode calcular fullName
a partir de firstName
e lastName
durante a renderização, então remova-o do estado.
Você pode fazer desta forma:
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>Vamos fazer seu check-in</h2> <label> Primeiro nome:{' '} <input value={firstName} onChange={handleFirstNameChange} /> </label> <label> Sobrenome:{' '} <input value={lastName} onChange={handleLastNameChange} /> </label> <p> Seu ticket será emitido para: <b>{fullName}</b> </p> </> ); }
Aqui, fullName
não é uma variável de estado. Em vez disso, ela é calculada durante a renderização:
const fullName = firstName + ' ' + lastName;
Como resultado, os manipuladores de mudança não precisam fazer nada de especial para atualizá-lo. Quando você chama setFirstName
ou setLastName
, você dispara uma nova renderização, e então o próximo fullName
será calculado a partir dos dados atualizados.
Deep Dive
Um exemplo comum de estado redundante são códigos como este:
function Message({ messageColor }) {
const [color, setColor] = useState(messageColor);
Aqui, uma variável de estado color
é inicializada com a prop messageColor
. O problema é que se o componente pai passar um valor diferente para messageColor
depois (por exemplo, 'red'
ao invés de 'blue'
), a variável de estado color
não seria atualizada! O estado é inicializado apenas durante a primeira renderização.
É por isso que “espelhar” alguma prop em uma variável de estado pode levar a confusão. Em vez disso, use a prop messageColor
diretamente no seu código. Se você quiser dar um nome mais curto para ela, use uma constante:
function Message({ messageColor }) {
const color = messageColor;
Desta forma, ela não ficará fora de sincronia com a prop passada pelo componente pai.
”Espelhar” props no estado só faz sentido quando você quer ignorar todas as atualizações para uma prop específica. Por convenção, comece o nome da prop com initial
ou default
para deixar claro que seus novos valores são ignorados:
function Message({ initialColor }) {
// A variável de estado `color` guarda o *primeiro* valor de `initialColor`.
// Mudanças posteriores na *prop* `initialColor` são ignoradas.
const [color, setColor] = useState(initialColor); */
Evite duplicação no estado
Este componente de lista de menus permite que você escolha um único lanche de viagem dentre vários:
import { useState } from 'react'; const initialItems = [ { title: 'pretzels', id: 0 }, { title: 'alga crocante', id: 1 }, { title: 'barra de granola', id: 2 }, ]; export default function Menu() { const [items, setItems] = useState(initialItems); const [selectedItem, setSelectedItem] = useState( items[0] ); return ( <> <h2>Qual o seu lanche de viagem?</h2> <ul> {items.map(item => ( <li key={item.id}> {item.title} {' '} <button onClick={() => { setSelectedItem(item); }}>Escolha</button> </li> ))} </ul> <p>Você selecionou {selectedItem.title}.</p> </> ); }
No momento, ele armazena o item selecionado como um objeto na variável de estado selectedItem
. No entanto, isso não é bom: o conteúdo de selectedItem
é o mesmo objeto que um dos itens dentro da lista items
. Isso significa que as informações sobre o item em si estão duplicadas em dois lugares.
Por que isso é um problema? Vamos tornar cada item editável:
import { useState } from 'react'; const initialItems = [ { title: 'pretzels', id: 0 }, { title: 'alga crocante', id: 1 }, { title: 'barra de granola', 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>Qual o seu lanche de viagem?</h2> <ul> {items.map((item, index) => ( <li key={item.id}> <input value={item.title} onChange={e => { handleItemChange(item.id, e) }} /> {' '} <button onClick={() => { setSelectedItem(item); }}>Escolha</button> </li> ))} </ul> <p>Você selecionou {selectedItem.title}.</p> </> ); }
Observe como se você clicar primeiro em “Escolha” em um item e depois editá-lo, a entrada é atualizada, mas o rótulo na parte inferior não reflete as edições. Isso ocorre porque você duplicou o estado e esqueceu de atualizar selectedItem
.
Embora você pudesse atualizar selectedItem
também, uma correção mais fácil é remover a duplicação. Neste exemplo, em vez de um objeto selectedItem
(que cria uma duplicação com objetos dentro de items
), você mantém o selectedId
no estado e depois obtém o selectedItem
pesquisando o array items
por um item com esse ID:
import { useState } from 'react'; const initialItems = [ { title: 'pretzels', id: 0 }, { title: 'alga crocante', id: 1 }, { title: 'barra de granola', 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>Qual o seu lanche de viagem?</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); }}>Escolha</button> </li> ))} </ul> <p>Você selecionou {selectedItem.title}.</p> </> ); }
O estado costumava ser duplicado assim:
items = [{ id: 0, title: 'pretzels'}, ...]
selectedItem = {id: 0, title: 'pretzels'}
Mas depois da mudança, é assim:
items = [{ id: 0, title: 'pretzels'}, ...]
selectedId = 0
A duplicação desapareceu, e você mantém apenas o estado essencial!
Agora, se você editar o item selecionado, a mensagem abaixo será atualizada imediatamente. Isso ocorre porque setItems
dispara uma nova renderização, e items.find(...)
encontraria o item com o título atualizado. Você não precisava manter o item selecionado no estado, porque apenas o ID selecionado é essencial. O resto poderia ser calculado durante a renderização.
Evite estados muito aninhados
Imagine um plano de viagem consistindo de planetas, continentes e países. Você pode ser tentado estruturar seu estado usando objetos e arrays aninhados, como neste exemplo:
export const initialTravelPlan = { id: 0, title: '(Root)', childPlaces: [{ id: 1, title: 'Terra', childPlaces: [{ id: 2, title: 'África', childPlaces: [{ id: 3, title: 'Botsuana', childPlaces: [] }, { id: 4, title: 'Egito', childPlaces: [] }, { id: 5, title: 'Kênia', childPlaces: [] }, { id: 6, title: 'Madagascar', childPlaces: [] }, { id: 7, title: 'Marrocos', childPlaces: [] }, { id: 8, title: 'Nigéria', childPlaces: [] }, { id: 9, title: 'África do Sul', childPlaces: [] }] }, { id: 10, title: 'Ámericas', childPlaces: [{ id: 11, title: 'Argentina', childPlaces: [] }, { id: 12, title: 'Brasil', childPlaces: [] }, { id: 13, title: 'Barbados', childPlaces: [] }, { id: 14, title: 'Canadá', childPlaces: [] }, { id: 15, title: 'Jamaica', childPlaces: [] }, { id: 16, title: 'México', childPlaces: [] }, { id: 17, title: 'Trindade e Tobago', childPlaces: [] }, { id: 18, title: 'Venezuela', childPlaces: [] }] }, { id: 19, title: 'Ásia', childPlaces: [{ id: 20, title: 'China', childPlaces: [] }, { id: 21, title: 'Índia', childPlaces: [] }, { id: 22, title: 'Singapura', childPlaces: [] }, { id: 23, title: 'Coreia do Sul', childPlaces: [] }, { id: 24, title: 'Tailândia', childPlaces: [] }, { id: 25, title: 'Vietnã', childPlaces: [] }] }, { id: 26, title: 'Europa', childPlaces: [{ id: 27, title: 'Croácia', childPlaces: [], }, { id: 28, title: 'França', childPlaces: [], }, { id: 29, title: 'Alemanha', childPlaces: [], }, { id: 30, title: 'Itália', childPlaces: [], }, { id: 31, title: 'Portugal', childPlaces: [], }, { id: 32, title: 'Espanha', childPlaces: [], }, { id: 33, title: 'Turquia', childPlaces: [], }] }, { id: 34, title: 'Oceania', childPlaces: [{ id: 35, title: 'Austrália', childPlaces: [], }, { id: 36, title: 'Bora Bora (Polinésia Francesa)', childPlaces: [], }, { id: 37, title: 'Ilha da Páscoa (Chile)', childPlaces: [], }, { id: 38, title: 'Fiji', childPlaces: [], }, { id: 39, title: 'Hawaii (EUA)', childPlaces: [], }, { id: 40, title: 'Nova Zelândia', childPlaces: [], }, { id: 41, title: 'Vanuatu', childPlaces: [], }] }] }, { id: 42, title: 'Lua', childPlaces: [{ id: 43, title: 'Rheita', childPlaces: [] }, { id: 44, title: 'Piccolomini', childPlaces: [] }, { id: 45, title: 'Tycho', childPlaces: [] }] }, { id: 46, title: 'Marte', childPlaces: [{ id: 47, title: 'Cidade do Milho', childPlaces: [] }, { id: 48, title: 'Monte Verde', childPlaces: [] }] }] };
Agora, digamos que você queira adicionar um botão para excluir um lugar que você já visitou. Como você faria isso? Atualizar estados aninhados envolve fazer cópias de objetos desde a parte que mudou. Excluir um lugar profundamente aninhado envolveria copiar toda a cadeia de lugares pai. Esse código pode ser muito verboso.
Se o estado for muito aninhado para ser atualizado facilmente, considere torná-lo “plano”. Aqui está uma maneira de você reestruturar esses dados. Em vez de uma estrutura em forma de árvore em que cada place
tem um array de seus lugares filhos, você pode fazer com que cada lugar mantenha um array de IDs dos seus lugares filhos. Em seguida, armazene um mapeamento de cada ID de lugar para o lugar correspondente.
Essa reestruturação de dados pode lembrá-lo de ver uma tabela de banco de dados:
export const initialTravelPlan = { 0: { id: 0, title: '(Root)', childIds: [1, 42, 46], }, 1: { id: 1, title: 'Terra', childIds: [2, 10, 19, 26, 34] }, 2: { id: 2, title: 'África', childIds: [3, 4, 5, 6 , 7, 8, 9] }, 3: { id: 3, title: 'Botsuana', childIds: [] }, 4: { id: 4, title: 'Egito', childIds: [] }, 5: { id: 5, title: 'Kênia', childIds: [] }, 6: { id: 6, title: 'Madagascar', childIds: [] }, 7: { id: 7, title: 'Marrocos', childIds: [] }, 8: { id: 8, title: 'Nigéria', childIds: [] }, 9: { id: 9, title: 'África do Sul', childIds: [] }, 10: { id: 10, title: 'Américas', childIds: [11, 12, 13, 14, 15, 16, 17, 18], }, 11: { id: 11, title: 'Argentina', childIds: [] }, 12: { id: 12, title: 'Brasil', childIds: [] }, 13: { id: 13, title: 'Barbados', childIds: [] }, 14: { id: 14, title: 'Canadá', childIds: [] }, 15: { id: 15, title: 'Jamaica', childIds: [] }, 16: { id: 16, title: 'México', childIds: [] }, 17: { id: 17, title: 'Trindade e Tobago', childIds: [] }, 18: { id: 18, title: 'Venezuela', childIds: [] }, 19: { id: 19, title: 'Ásia', childIds: [20, 21, 22, 23, 24, 25], }, 20: { id: 20, title: 'China', childIds: [] }, 21: { id: 21, title: 'Índia', childIds: [] }, 22: { id: 22, title: 'Singapura', childIds: [] }, 23: { id: 23, title: 'Coreia do Sul', childIds: [] }, 24: { id: 24, title: 'Tailândia', childIds: [] }, 25: { id: 25, title: 'Vietnã', childIds: [] }, 26: { id: 26, title: 'Europa', childIds: [27, 28, 29, 30, 31, 32, 33], }, 27: { id: 27, title: 'Croácia', childIds: [] }, 28: { id: 28, title: 'França', childIds: [] }, 29: { id: 29, title: 'Alemanha', childIds: [] }, 30: { id: 30, title: 'Itália', childIds: [] }, 31: { id: 31, title: 'Portugal', childIds: [] }, 32: { id: 32, title: 'Espanha', childIds: [] }, 33: { id: 33, title: 'Turquia', childIds: [] }, 34: { id: 34, title: 'Oceania', childIds: [35, 36, 37, 38, 39, 40, 41], }, 35: { id: 35, title: 'Austrália', childIds: [] }, 36: { id: 36, title: 'Bora Bora (Polinésia Francesa)', childIds: [] }, 37: { id: 37, title: 'Ilha de Páscoa (Chile)', childIds: [] }, 38: { id: 38, title: 'Fiji', childIds: [] }, 39: { id: 39, title: 'Hawaii (EUA)', childIds: [] }, 40: { id: 40, title: 'Nova Zelândia', childIds: [] }, 41: { id: 41, title: 'Vanuatu', childIds: [] }, 42: { id: 42, title: 'Lua', childIds: [43, 44, 45] }, 43: { id: 43, title: 'Rheita', childIds: [] }, 44: { id: 44, title: 'Piccolomini', childIds: [] }, 45: { id: 45, title: 'Tycho', childIds: [] }, 46: { id: 46, title: 'Marte', childIds: [47, 48] }, 47: { id: 47, title: 'Cidade do Milho', childIds: [] }, 48: { id: 48, title: 'Monte Verde', childIds: [] } };
Agora que o estado está “plano” (também conhecido como “normalizado”), atualizar itens aninhados fica mais fácil.
Para remover um lugar agora, você só precisa atualizar dois níveis de estado:
- A versão atualizada de seu lugar pai deve excluir o ID removido de seu array
childIds
. - A versão atualizada do objeto “tabela” raiz deve incluir a versão atualizada do lugar pai.
Aqui está um exemplo de como você poderia fazer isso:
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]; // Cria uma nova versão do lugar pai // que não inclui o ID deste filho. const nextParent = { ...parent, childIds: parent.childIds .filter(id => id !== childId) }; // Atualiza o objeto de estado raiz... setPlan({ ...plan, // ...para que tenha o pai atualizado. [parentId]: nextParent }); } const root = plan[0]; const planetIds = root.childIds; return ( <> <h2>Lugares para visitar</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); }}> Completar </button> {childIds.length > 0 && <ol> {childIds.map(childId => ( <PlaceTree key={childId} id={childId} parentId={id} placesById={placesById} onComplete={onComplete} /> ))} </ol> } </li> ); }
Você pode aninhar o estado o quanto quiser, mas torná-lo “plano” pode resolver inúmeros problemas. Isso torna o estado mais fácil de atualizar, e ajuda a garantir que você não tenha duplicação em diferentes partes de um objeto aninhado.
Deep Dive
Idealmente, você também removeria os itens excluídos (e seus filhos!) do objeto “tabela” para melhorar o uso da memória. Esta versão faz isso. Ele também usa Immer para tornar a lógica de atualização mais concisa.
{ "dependencies": { "immer": "1.7.3", "react": "latest", "react-dom": "latest", "react-scripts": "latest", "use-immer": "0.5.1" }, "scripts": { "start": "react-scripts start", "build": "react-scripts build", "test": "react-scripts test --env=jsdom", "eject": "react-scripts eject" }, "devDependencies": {} }
Por vezes, você também pode reduzir o aninhamento do estado movendo parte do estado aninhado para os componentes filhos. Isso funciona bem para o estado de UI efêmero que não precisa ser armazenado, como saber se o mouse está passando sobre um item.
Recap
- Se duas variáveis de estado sempre são atualizadas juntas, considere uní-las em uma.
- Escolha suas variáveis de estado cuidadosamente para evitar criar estados “impossíveis”.
- Estruture seu estado de uma maneira que reduza as chances de você cometer um erro ao atualizá-lo.
- Evite estados redundantes e duplicados para que você não precise mantê-los sincronizados.
- Não coloque props dentro de estados a menos que você queira especificamente impedir atualizações.
- Para padrões de UI como seleção, mantenha o ID ou o índice no estado em vez do objeto em si.
- Se atualizar o estado profundamente aninhado for complicado, tente achatá-lo.
Challenge 1 of 4: Corrija um componente que não está sendo atualizado
Este componente Clock
recebe duas props: color
e time
. Quando você seleciona uma cor diferente na caixa de seleção, o componente Clock
recebe uma prop color
diferente de seu componente pai. No entanto, por algum motivo, a cor exibida não é atualizada. Por quê? Corrija o problema.
import { useState } from 'react'; export default function Clock(props) { const [color, setColor] = useState(props.color); return ( <h1 style={{ color: color }}> {props.time} </h1> ); }