Movatterモバイル変換


[0]ホーム

URL:


Is this page useful?

Choosing the State Structure

Structuring state well can make a difference between a component that is pleasant to modify and debug, and one that is a constant source of bugs. Here are some tips you should consider when structuring state.

You will learn

  • When to use a single vs multiple state variables
  • What to avoid when organizing state
  • How to fix common issues with the state structure

Principles for structuring state

When you write a component that holds some state, you’ll have to make choices about how many state variables to use and what the shape of their data should be. While it’s possible to write correct programs even with a suboptimal state structure, there are a few principles that can guide you to make better choices:

  1. Group related state. If you always update two or more state variables at the same time, consider merging them into a single state variable.
  2. Avoid contradictions in state. When the state is structured in a way that several pieces of state may contradict and “disagree” with each other, you leave room for mistakes. Try to avoid this.
  3. Avoid redundant state. If you can calculate some information from the component’s props or its existing state variables during rendering, you should not put that information into that component’s state.
  4. Avoid duplication in state. When the same data is duplicated between multiple state variables, or within nested objects, it is difficult to keep them in sync. Reduce duplication when you can.
  5. Avoid deeply nested state. Deeply hierarchical state is not very convenient to update. When possible, prefer to structure state in a flat way.

The goal behind these principles is tomake state easy to update without introducing mistakes. Removing redundant and duplicate data from state helps ensure that all its pieces stay in sync. This is similar to how a database engineer might want to“normalize” the database structure to reduce the chance of bugs. To paraphrase Albert Einstein,“Make your state as simple as it can be—but no simpler.”

Now let’s see how these principles apply in action.

Group related state

You might sometimes be unsure between using a single or multiple state variables.

Should you do this?

const[x,setX] =useState(0);
const[y,setY] =useState(0);

Or this?

const[position,setPosition] =useState({x:0,y:0});

Technically, you can use either of these approaches. Butif some two state variables always change together, it might be a good idea to unify them into a single state variable. Then you won’t forget to always keep them in sync, like in this example where moving the cursor updates both coordinates of the red dot:

Fork
import{useState}from'react';exportdefaultfunctionMovingDot(){const[position,setPosition] =useState({x:0,y:0});return(<divonPointerMove={e=>{setPosition({x:e.clientX,y:e.clientY});}}style={{position:'relative',width:'100vw',height:'100vh',}}><divstyle={{position:'absolute',backgroundColor:'red',borderRadius:'50%',transform:`translate(${position.x}px,${position.y}px)`,left: -10,top: -10,width:20,height:20,}}/></div>)}

Another case where you’ll group data into an object or an array is when you don’t know how many pieces of state you’ll need. For example, it’s helpful when you have a form where the user can add custom fields.

Pitfall

If your state variable is an object, remember thatyou can’t update only one field in it without explicitly copying the other fields. For example, you can’t dosetPosition({ x: 100 }) in the above example because it would not have they property at all! Instead, if you wanted to setx alone, you would either dosetPosition({ ...position, x: 100 }), or split them into two state variables and dosetX(100).

Avoid contradictions in state

Here is a hotel feedback form withisSending andisSent state variables:

Fork
import{useState}from'react';exportdefaultfunctionFeedbackForm(){const[text,setText] =useState('');const[isSending,setIsSending] =useState(false);const[isSent,setIsSent] =useState(false);asyncfunctionhandleSubmit(e){e.preventDefault();setIsSending(true);awaitsendMessage(text);setIsSending(false);setIsSent(true);}if(isSent){return<h1>Thanks for feedback!</h1>}return(<formonSubmit={handleSubmit}><p>How was your stay at The Prancing Pony?</p><textareadisabled={isSending}value={text}onChange={e=>setText(e.target.value)}/><br/><buttondisabled={isSending}type="submit">        Send</button>{isSending &&<p>Sending...</p>}</form>);}// Pretend to send a message.functionsendMessage(text){returnnewPromise(resolve=>{setTimeout(resolve,2000);});}

While this code works, it leaves the door open for “impossible” states. For example, if you forget to callsetIsSent andsetIsSending together, you may end up in a situation where bothisSending andisSent aretrue at the same time. The more complex your component is, the harder it is to understand what happened.

SinceisSending andisSent should never betrue at the same time, it is better to replace them with onestatus state variable that may take one ofthree valid states:'typing' (initial),'sending', and'sent':

Fork
import{useState}from'react';exportdefaultfunctionFeedbackForm(){const[text,setText] =useState('');const[status,setStatus] =useState('typing');asyncfunctionhandleSubmit(e){e.preventDefault();setStatus('sending');awaitsendMessage(text);setStatus('sent');}constisSending =status ==='sending';constisSent =status ==='sent';if(isSent){return<h1>Thanks for feedback!</h1>}return(<formonSubmit={handleSubmit}><p>How was your stay at The Prancing Pony?</p><textareadisabled={isSending}value={text}onChange={e=>setText(e.target.value)}/><br/><buttondisabled={isSending}type="submit">        Send</button>{isSending &&<p>Sending...</p>}</form>);}// Pretend to send a message.functionsendMessage(text){returnnewPromise(resolve=>{setTimeout(resolve,2000);});}

You can still declare some constants for readability:

constisSending =status ==='sending';
constisSent =status ==='sent';

But they’re not state variables, so you don’t need to worry about them getting out of sync with each other.

Avoid redundant state

If you can calculate some information from the component’s props or its existing state variables during rendering, youshould not put that information into that component’s state.

For example, take this form. It works, but can you find any redundant state in it?

Fork
import{useState}from'react';exportdefaultfunctionForm(){const[firstName,setFirstName] =useState('');const[lastName,setLastName] =useState('');const[fullName,setFullName] =useState('');functionhandleFirstNameChange(e){setFirstName(e.target.value);setFullName(e.target.value +' ' +lastName);}functionhandleLastNameChange(e){setLastName(e.target.value);setFullName(firstName +' ' +e.target.value);}return(<><h2>Let’s check you in</h2><label>        First name:{' '}<inputvalue={firstName}onChange={handleFirstNameChange}/></label><label>        Last name:{' '}<inputvalue={lastName}onChange={handleLastNameChange}/></label><p>        Your ticket will be issued to:<b>{fullName}</b></p></>);}

This form has three state variables:firstName,lastName, andfullName. However,fullName is redundant.You can always calculatefullName fromfirstName andlastName during render, so remove it from state.

This is how you can do it:

Fork
import{useState}from'react';exportdefaultfunctionForm(){const[firstName,setFirstName] =useState('');const[lastName,setLastName] =useState('');constfullName =firstName +' ' +lastName;functionhandleFirstNameChange(e){setFirstName(e.target.value);}functionhandleLastNameChange(e){setLastName(e.target.value);}return(<><h2>Let’s check you in</h2><label>        First name:{' '}<inputvalue={firstName}onChange={handleFirstNameChange}/></label><label>        Last name:{' '}<inputvalue={lastName}onChange={handleLastNameChange}/></label><p>        Your ticket will be issued to:<b>{fullName}</b></p></>);}

Here,fullName isnot a state variable. Instead, it’s calculated during render:

constfullName =firstName +' ' +lastName;

As a result, the change handlers don’t need to do anything special to update it. When you callsetFirstName orsetLastName, you trigger a re-render, and then the nextfullName will be calculated from the fresh data.

Deep Dive

Don’t mirror props in state

A common example of redundant state is code like this:

functionMessage({messageColor}){
const[color,setColor] =useState(messageColor);

Here, acolor state variable is initialized to themessageColor prop. The problem is thatif the parent component passes a different value ofmessageColor later (for example,'red' instead of'blue'), thecolorstate variable would not be updated! The state is only initialized during the first render.

This is why “mirroring” some prop in a state variable can lead to confusion. Instead, use themessageColor prop directly in your code. If you want to give it a shorter name, use a constant:

functionMessage({messageColor}){
constcolor =messageColor;

This way it won’t get out of sync with the prop passed from the parent component.

”Mirroring” props into state only makes sense when youwant to ignore all updates for a specific prop. By convention, start the prop name withinitial ordefault to clarify that its new values are ignored:

functionMessage({initialColor}){
// The `color` state variable holds the *first* value of `initialColor`.
// Further changes to the `initialColor` prop are ignored.
const[color,setColor] =useState(initialColor);

Avoid duplication in state

This menu list component lets you choose a single travel snack out of several:

Fork
import{useState}from'react';constinitialItems =[{title:'pretzels',id:0},{title:'crispy seaweed',id:1},{title:'granola bar',id:2},];exportdefaultfunctionMenu(){const[items,setItems] =useState(initialItems);const[selectedItem,setSelectedItem] =useState(items[0]);return(<><h2>What's your travel snack?</h2><ul>{items.map(item=>(<likey={item.id}>{item.title}{' '}<buttononClick={()=>{setSelectedItem(item);}}>Choose</button></li>))}</ul><p>You picked{selectedItem.title}.</p></>);}

Currently, it stores the selected item as an object in theselectedItem state variable. However, this is not great:the contents of theselectedItem is the same object as one of the items inside theitems list. This means that the information about the item itself is duplicated in two places.

Why is this a problem? Let’s make each item editable:

Fork
import{useState}from'react';constinitialItems =[{title:'pretzels',id:0},{title:'crispy seaweed',id:1},{title:'granola bar',id:2},];exportdefaultfunctionMenu(){const[items,setItems] =useState(initialItems);const[selectedItem,setSelectedItem] =useState(items[0]);functionhandleItemChange(id,e){setItems(items.map(item=>{if(item.id ===id){return{...item,title:e.target.value,};}else{returnitem;}}));}return(<><h2>What's your travel snack?</h2><ul>{items.map((item,index)=>(<likey={item.id}><inputvalue={item.title}onChange={e=>{handleItemChange(item.id,e)}}/>{' '}<buttononClick={()=>{setSelectedItem(item);}}>Choose</button></li>))}</ul><p>You picked{selectedItem.title}.</p></>);}

Notice how if you first click “Choose” on an item andthen edit it,the input updates but the label at the bottom does not reflect the edits. This is because you have duplicated state, and you forgot to updateselectedItem.

Although you could updateselectedItem too, an easier fix is to remove duplication. In this example, instead of aselectedItem object (which creates a duplication with objects insideitems), you hold theselectedId in state, andthen get theselectedItem by searching theitems array for an item with that ID:

Fork
import{useState}from'react';constinitialItems =[{title:'pretzels',id:0},{title:'crispy seaweed',id:1},{title:'granola bar',id:2},];exportdefaultfunctionMenu(){const[items,setItems] =useState(initialItems);const[selectedId,setSelectedId] =useState(0);constselectedItem =items.find(item=>item.id ===selectedId);functionhandleItemChange(id,e){setItems(items.map(item=>{if(item.id ===id){return{...item,title:e.target.value,};}else{returnitem;}}));}return(<><h2>What's your travel snack?</h2><ul>{items.map((item,index)=>(<likey={item.id}><inputvalue={item.title}onChange={e=>{handleItemChange(item.id,e)}}/>{' '}<buttononClick={()=>{setSelectedId(item.id);}}>Choose</button></li>))}</ul><p>You picked{selectedItem.title}.</p></>);}

The state used to be duplicated like this:

  • items = [{ id: 0, title: 'pretzels'}, ...]
  • selectedItem = {id: 0, title: 'pretzels'}

But after the change it’s like this:

  • items = [{ id: 0, title: 'pretzels'}, ...]
  • selectedId = 0

The duplication is gone, and you only keep the essential state!

Now if you edit theselected item, the message below will update immediately. This is becausesetItems triggers a re-render, anditems.find(...) would find the item with the updated title. You didn’t need to holdthe selected item in state, because only theselected ID is essential. The rest could be calculated during render.

Avoid deeply nested state

Imagine a travel plan consisting of planets, continents, and countries. You might be tempted to structure its state using nested objects and arrays, like in this example:

Fork
exportconstinitialTravelPlan ={id:0,title:'(Root)',childPlaces:[{id:1,title:'Earth',childPlaces:[{id:2,title:'Africa',childPlaces:[{id:3,title:'Botswana',childPlaces:[]},{id:4,title:'Egypt',childPlaces:[]},{id:5,title:'Kenya',childPlaces:[]},{id:6,title:'Madagascar',childPlaces:[]},{id:7,title:'Morocco',childPlaces:[]},{id:8,title:'Nigeria',childPlaces:[]},{id:9,title:'South Africa',childPlaces:[]}]},{id:10,title:'Americas',childPlaces:[{id:11,title:'Argentina',childPlaces:[]},{id:12,title:'Brazil',childPlaces:[]},{id:13,title:'Barbados',childPlaces:[]},{id:14,title:'Canada',childPlaces:[]},{id:15,title:'Jamaica',childPlaces:[]},{id:16,title:'Mexico',childPlaces:[]},{id:17,title:'Trinidad and Tobago',childPlaces:[]},{id:18,title:'Venezuela',childPlaces:[]}]},{id:19,title:'Asia',childPlaces:[{id:20,title:'China',childPlaces:[]},{id:21,title:'India',childPlaces:[]},{id:22,title:'Singapore',childPlaces:[]},{id:23,title:'South Korea',childPlaces:[]},{id:24,title:'Thailand',childPlaces:[]},{id:25,title:'Vietnam',childPlaces:[]}]},{id:26,title:'Europe',childPlaces:[{id:27,title:'Croatia',childPlaces:[],},{id:28,title:'France',childPlaces:[],},{id:29,title:'Germany',childPlaces:[],},{id:30,title:'Italy',childPlaces:[],},{id:31,title:'Portugal',childPlaces:[],},{id:32,title:'Spain',childPlaces:[],},{id:33,title:'Turkey',childPlaces:[],}]},{id:34,title:'Oceania',childPlaces:[{id:35,title:'Australia',childPlaces:[],},{id:36,title:'Bora Bora (French Polynesia)',childPlaces:[],},{id:37,title:'Easter Island (Chile)',childPlaces:[],},{id:38,title:'Fiji',childPlaces:[],},{id:39,title:'Hawaii (the USA)',childPlaces:[],},{id:40,title:'New Zealand',childPlaces:[],},{id:41,title:'Vanuatu',childPlaces:[],}]}]},{id:42,title:'Moon',childPlaces:[{id:43,title:'Rheita',childPlaces:[]},{id:44,title:'Piccolomini',childPlaces:[]},{id:45,title:'Tycho',childPlaces:[]}]},{id:46,title:'Mars',childPlaces:[{id:47,title:'Corn Town',childPlaces:[]},{id:48,title:'Green Hill',childPlaces:[]}]}]};

Now let’s say you want to add a button to delete a place you’ve already visited. How would you go about it?Updating nested state involves making copies of objects all the way up from the part that changed. Deleting a deeply nested place would involve copying its entire parent place chain. Such code can be very verbose.

If the state is too nested to update easily, consider making it “flat”. Here is one way you can restructure this data. Instead of a tree-like structure where eachplace has an array ofits child places, you can have each place hold an array ofits child place IDs. Then store a mapping from each place ID to the corresponding place.

This data restructuring might remind you of seeing a database table:

Fork
exportconstinitialTravelPlan ={0:{id:0,title:'(Root)',childIds:[1,42,46],},1:{id:1,title:'Earth',childIds:[2,10,19,26,34]},2:{id:2,title:'Africa',childIds:[3,4,5,6,7,8,9]},3:{id:3,title:'Botswana',childIds:[]},4:{id:4,title:'Egypt',childIds:[]},5:{id:5,title:'Kenya',childIds:[]},6:{id:6,title:'Madagascar',childIds:[]},7:{id:7,title:'Morocco',childIds:[]},8:{id:8,title:'Nigeria',childIds:[]},9:{id:9,title:'South Africa',childIds:[]},10:{id:10,title:'Americas',childIds:[11,12,13,14,15,16,17,18],},11:{id:11,title:'Argentina',childIds:[]},12:{id:12,title:'Brazil',childIds:[]},13:{id:13,title:'Barbados',childIds:[]},14:{id:14,title:'Canada',childIds:[]},15:{id:15,title:'Jamaica',childIds:[]},16:{id:16,title:'Mexico',childIds:[]},17:{id:17,title:'Trinidad and Tobago',childIds:[]},18:{id:18,title:'Venezuela',childIds:[]},19:{id:19,title:'Asia',childIds:[20,21,22,23,24,25],},20:{id:20,title:'China',childIds:[]},21:{id:21,title:'India',childIds:[]},22:{id:22,title:'Singapore',childIds:[]},23:{id:23,title:'South Korea',childIds:[]},24:{id:24,title:'Thailand',childIds:[]},25:{id:25,title:'Vietnam',childIds:[]},26:{id:26,title:'Europe',childIds:[27,28,29,30,31,32,33],},27:{id:27,title:'Croatia',childIds:[]},28:{id:28,title:'France',childIds:[]},29:{id:29,title:'Germany',childIds:[]},30:{id:30,title:'Italy',childIds:[]},31:{id:31,title:'Portugal',childIds:[]},32:{id:32,title:'Spain',childIds:[]},33:{id:33,title:'Turkey',childIds:[]},34:{id:34,title:'Oceania',childIds:[35,36,37,38,39,40,41],},35:{id:35,title:'Australia',childIds:[]},36:{id:36,title:'Bora Bora (French Polynesia)',childIds:[]},37:{id:37,title:'Easter Island (Chile)',childIds:[]},38:{id:38,title:'Fiji',childIds:[]},39:{id:40,title:'Hawaii (the USA)',childIds:[]},40:{id:40,title:'New Zealand',childIds:[]},41:{id:41,title:'Vanuatu',childIds:[]},42:{id:42,title:'Moon',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:'Mars',childIds:[47,48]},47:{id:47,title:'Corn Town',childIds:[]},48:{id:48,title:'Green Hill',childIds:[]}};

Now that the state is “flat” (also known as “normalized”), updating nested items becomes easier.

In order to remove a place now, you only need to update two levels of state:

  • The updated version of itsparent place should exclude the removed ID from itschildIds array.
  • The updated version of the root “table” object should include the updated version of the parent place.

Here is an example of how you could go about it:

Fork
import{useState}from'react';import{initialTravelPlan}from'./places.js';exportdefaultfunctionTravelPlan(){const[plan,setPlan] =useState(initialTravelPlan);functionhandleComplete(parentId,childId){constparent =plan[parentId];// Create a new version of the parent place// that doesn't include this child ID.constnextParent ={...parent,childIds:parent.childIds        .filter(id=>id !==childId)};// Update the root state object...setPlan({...plan,// ...so that it has the updated parent.[parentId]:nextParent});}constroot =plan[0];constplanetIds =root.childIds;return(<><h2>Places to visit</h2><ol>{planetIds.map(id=>(<PlaceTreekey={id}id={id}parentId={0}placesById={plan}onComplete={handleComplete}/>))}</ol></>);}functionPlaceTree({id,parentId,placesById,onComplete}){constplace =placesById[id];constchildIds =place.childIds;return(<li>{place.title}<buttononClick={()=>{onComplete(parentId,id);}}>        Complete</button>{childIds.length >0 &&<ol>{childIds.map(childId=>(<PlaceTreekey={childId}id={childId}parentId={id}placesById={placesById}onComplete={onComplete}/>))}</ol>}</li>);}

You can nest state as much as you like, but making it “flat” can solve numerous problems. It makes state easier to update, and it helps ensure you don’t have duplication in different parts of a nested object.

Deep Dive

Improving memory usage

Ideally, you would also remove the deleted items (and their children!) from the “table” object to improve memory usage. This version does that. It alsouses Immer to make the update logic more concise.

Fork
{"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":{}}

Sometimes, you can also reduce state nesting by moving some of the nested state into the child components. This works well for ephemeral UI state that doesn’t need to be stored, like whether an item is hovered.

Recap

  • If two state variables always update together, consider merging them into one.
  • Choose your state variables carefully to avoid creating “impossible” states.
  • Structure your state in a way that reduces the chances that you’ll make a mistake updating it.
  • Avoid redundant and duplicate state so that you don’t need to keep it in sync.
  • Don’t put propsinto state unless you specifically want to prevent updates.
  • For UI patterns like selection, keep ID or index in state instead of the object itself.
  • If updating deeply nested state is complicated, try flattening it.

Try out some challenges

Challenge 1 of 4:
Fix a component that’s not updating

ThisClock component receives two props:color andtime. When you select a different color in the select box, theClock component receives a differentcolor prop from its parent component. However, for some reason, the displayed color doesn’t update. Why? Fix the problem.

Fork
import{useState}from'react';exportdefaultfunctionClock(props){const[color,setColor] =useState(props.color);return(<h1style={{color:color}}>{props.time}</h1>);}


[8]ページ先頭

©2009-2025 Movatter.jp