Choosing the State Structure
state の構造を考えるときに,考慮すべき原則は以下。
- 関連する state をグループ化する。 二つ以上の state を常に同時に更新しているなら,それらの state を単一の state に統合することを考えるべき。
- state 間での矛盾を避ける。
- 冗長な state を避ける。 props や既存の state から算出できる情報を新たに state で管理すべきではない。
- 重複した state を避ける。
- 深くネストした state を避ける。 深い階層の state は更新がしにくい。
Group related state
import { useState } from "react"; function App() { 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> ); } export default App;
position
のx
,y
プロパティは常に同時に更新されるため,単一の state で管理した方がよい。
Avoid contradictions in state
import { FormEvent, useState } from "react"; function App() { const [text, setText] = useState(""); const [isSending, setIsSending] = useState(false); const [isSent, setIsSent] = useState(false); async function handleSubmit(e: FormEvent<HTMLFormElement>) { 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 Pricing 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> ); } function sendMessage(text: string): Promise<void> { return new Promise((resolve) => { setTimeout(resolve, 2000); }); } export default App;
上記のコードだと,isSending
とisSent
が両方とも true という矛盾した state が存在する可能性がある。status
というユニオン型を持つ state を導入することで解決できる。
import { FormEvent, useState } from "react"; function App() { const [text, setText] = useState(""); const [status, setStatus] = (useState < "typing") | "sending" | ("sent" > "typing"); async function handleSubmit(e: FormEvent<HTMLFormElement>) { 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 Pricing 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> ); } function sendMessage(text: string): Promise<void> { return new Promise((resolve) => { setTimeout(resolve, 2000); }); } export default App;
typing
,sending
,sent
というユニオン型を持つstatus
state を定義することで,矛盾した state が発生しなくなった。
Avoid redundant state
import { ChangeEvent, FormEvent, useState } from "react"; function App() { const [firstName, setFirstName] = useState(""); const [lastName, setLastNaem] = useState(""); const [fullName, setFullName] = useState(""); function handleFirstNameChange(e: ChangeEvent<HTMLInputElement>) { setFirstName(e.target.value); setFullName(e.target.value + " " + lastName); } function handleLastNameChange(e: ChangeEvent<HTMLInputElement>) { setLastNaem(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> </> ); } export default App;
fullName
はfirstName
とlastName
から算出可能な値であり,冗長な state となっている。
以下のように,fullName = firstName + ' ' + lastName
でfullName
を直接算出すれば良い。
import { ChangeEvent, useState } from "react"; function App() { const [firstName, setFirstName] = useState(""); const [lastName, setLastName] = useState(""); const fullName = firstName + " " + lastName; function handleFirstNameChange(e: ChangeEvent<HTMLInputElement>) { setFirstName(e.target.value); } function handleLastNameChange(e: ChangeEvent<HTMLInputElement>) { 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> </> ); } export default App;
Don't mirror props in state
import { useState } from "react"; function App() { const [firstName, setFirstName] = useState(""); return ( <> <label>Parent First Name</label> <input onChange={(e) => setFirstName(e.target.value)} /> <label>Child First Name</label> <FirstName firstName={firstName} /> </> ); } type FirstNameProps = { firstName: string, }; function FirstName({ firstName }: FirstNameProps) { const [name, setName] = useState(firstName); return <input value={name} onChange={(e) => setName(e.target.value)} />; } export default App;
上記のFirstName
コンポーネントは,firstName
を props として受け取り,useState の引数として受け取っている。しかし,App
コンポーネント内のfirstName
が更新され,FirstName
コンポーネントのfirstName
props が更新されても,useState の値は更新されない。なぜなら,useState の初期化は最初のレンダリングのみで行われるため。
Avoid duplication in state
import { ChangeEvent, useState } from "react"; const initialItems = [ { title: "pretzels", id: 0 }, { title: "crispy seaweed", id: 1 }, { title: "granola bar", id: 2 }, ]; function App() { const [items, setItems] = useState(initialItems); const [selectedItem, setSelectedItem] = useState(items[0]); function handleItemChange(id: number, e: ChangeEvent<HTMLInputElement>) { 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) => ( <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> </> ); } export default App;
上記の実装だと,selectedItem
とitems
内に存在するitem
で重複が生まれてしまう。そのため,items
に変更が加えられても,selectedItem
に反映されない。selectedId
という state を持つことで state の重複を回避できる。
import { ChangeEvent, useState } from "react"; const initialItems = [ { title: "pretzels", id: 0 }, { title: "crispy seaweed", id: 1 }, { title: "granola bar", id: 2 }, ]; function App() { const [items, setItems] = useState(initialItems); const [selectedId, setSelectedId] = useState(0); const selectedItem = items.find((item) => item.id === selectedId); function handleItemChange(id: number, e: ChangeEvent<HTMLInputElement>) { 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) => ( <li key={item.id}> <input value={item.title} onChange={(e) => handleItemChange(item.id, e)} />{" "} <button onClick={() => { setSelectedId(item.id); }} > Choose </button> </li> ))} </ul> {selectedItem && <p>You picked {selectedItem.title}</p>} </> ); } export default App;
Avoid deeply nested state
import { ChangeEvent, useState } from "react"; const initialTravelPlan = { 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: "Hong Kong", childPlaces: [], }, { id: 22, title: "India", childPlaces: [], }, { id: 23, title: "Singapore", childPlaces: [], }, { id: 24, title: "South Korea", childPlaces: [], }, { id: 25, title: "Thailand", childPlaces: [], }, { id: 26, title: "Vietnam", childPlaces: [], }, ], }, { id: 27, title: "Europe", childPlaces: [ { id: 28, title: "Croatia", childPlaces: [], }, { id: 29, title: "France", childPlaces: [], }, { id: 30, title: "Germany", childPlaces: [], }, { id: 31, title: "Italy", childPlaces: [], }, { id: 32, title: "Portugal", childPlaces: [], }, { id: 33, title: "Spain", childPlaces: [], }, { id: 34, title: "Turkey", childPlaces: [], }, ], }, { id: 35, title: "Oceania", childPlaces: [ { id: 36, title: "Australia", childPlaces: [], }, { id: 37, title: "Bora Bora (French Polynesia)", childPlaces: [], }, { id: 38, title: "Easter Island (Chile)", childPlaces: [], }, { id: 39, title: "Fiji", childPlaces: [], }, { id: 40, title: "Hawaii (the USA)", childPlaces: [], }, { id: 41, title: "New Zealand", childPlaces: [], }, { id: 42, title: "Vanuatu", childPlaces: [], }, ], }, ], }, { id: 43, title: "Moon", childPlaces: [ { id: 44, title: "Rheita", childPlaces: [], }, { id: 45, title: "Piccolomini", childPlaces: [], }, { id: 46, title: "Tycho", childPlaces: [], }, ], }, { id: 47, title: "Mars", childPlaces: [ { id: 48, title: "Corn Town", childPlaces: [], }, { id: 49, title: "Green Hill", childPlaces: [], }, ], }, ], }; type PlaceTreeProps = { id: number, title: string, childPlaces: PlaceTreeProps[], }; function PlaceTree({ id, title, childPlaces }: PlaceTreeProps) { return ( <> <li>{title}</li> {childPlaces.length > 0 && ( <ol> {childPlaces.map((place) => ( <PlaceTree key={place.id} id={place.id} title={place.title} childPlaces={place.childPlaces} /> ))} </ol> )} </> ); } function App() { const [plan, setPlan] = useState(initialTravelPlan); const planets = plan.childPlaces; return ( <> <h2>Places to visit</h2> <ol> {planets.map((place) => ( <PlaceTree key={place.id} id={place.id} title={place.title} childPlaces={place.childPlaces} /> ))} </ol> </> ); } export default App;
上記のような実装だと,ネストが深く更新が行いにくい。そのため,構造を以下のようにフラットにする。
import { useState } from "react"; type Plan = { id: number, title: string, childIds: number[] }; type TravelPlan = { [key: number]: Plan, }; const initialTravelPlan: TravelPlan = { 0: { id: 0, title: "(Root)", childIds: [1, 43, 47], }, 1: { id: 1, title: "Earth", childIds: [2, 10, 19, 27, 35], }, 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, 26], }, 20: { id: 20, title: "China", childIds: [], }, 21: { id: 21, title: "Hong Kong", childIds: [], }, 22: { id: 22, title: "India", childIds: [], }, 23: { id: 23, title: "Singapore", childIds: [], }, 24: { id: 24, title: "South Korea", childIds: [], }, 25: { id: 25, title: "Thailand", childIds: [], }, 26: { id: 26, title: "Vietnam", childIds: [], }, 27: { id: 27, title: "Europe", childIds: [28, 29, 30, 31, 32, 33, 34], }, 28: { id: 28, title: "Croatia", childIds: [], }, 29: { id: 29, title: "France", childIds: [], }, 30: { id: 30, title: "Germany", childIds: [], }, 31: { id: 31, title: "Italy", childIds: [], }, 32: { id: 32, title: "Portugal", childIds: [], }, 33: { id: 33, title: "Spain", childIds: [], }, 34: { id: 34, title: "Turkey", childIds: [], }, 35: { id: 35, title: "Oceania", childIds: [36, 37, 38, 39, 40, 41, 42], }, 36: { id: 36, title: "Australia", childIds: [], }, 37: { id: 37, title: "Bora Bora (French Polynesia)", childIds: [], }, 38: { id: 38, title: "Easter Island (Chile)", childIds: [], }, 39: { id: 39, title: "Fiji", childIds: [], }, 40: { id: 40, title: "Hawaii (the USA)", childIds: [], }, 41: { id: 41, title: "New Zealand", childIds: [], }, 42: { id: 42, title: "Vanuatu", childIds: [], }, 43: { id: 43, title: "Moon", childIds: [44, 45, 46], }, 44: { id: 44, title: "Rheita", childIds: [], }, 45: { id: 45, title: "Piccolomini", childIds: [], }, 46: { id: 46, title: "Tycho", childIds: [], }, 47: { id: 47, title: "Mars", childIds: [48, 49], }, 48: { id: 48, title: "Corn Town", childIds: [], }, 49: { id: 49, title: "Green Hill", childIds: [], }, }; type PlaceTreeProps = { id: number, parentId: number, placesById: TravelPlan, onComplete: (parentId: number, id: number) => void, }; function PlaceTree({ id, parentId, placesById, onComplete }: PlaceTreeProps) { const place = placesById[id]; const childIds = place.childIds; return ( <> <li> {place.title} <button onClick={() => onComplete(parentId, id)}>Complete</button> </li> {childIds.length > 0 && ( <ol> {childIds.map((childId) => ( <PlaceTree key={childId} id={childId} parentId={id} placesById={placesById} onComplete={onComplete} /> ))} </ol> )} </> ); } function App() { const [plan, setPlan] = useState(initialTravelPlan); const root = plan[0]; const planetIds = root.childIds; function handleComplete(parentId: number, childId: number) { const parent = plan[parentId]; const nextParent: Plan = { ...parent, childIds: parent.childIds.filter((id) => id !== childId), }; setPlan({ ...plan, [parentId]: nextParent, }); } return ( <> <h2>Places to visit</h2> <ol> {planetIds.map((id) => ( <PlaceTree key={id} id={id} parentId={0} placesById={plan} onComplete={handleComplete} /> ))} </ol> </> ); } export default App;