はじめに
useState は書ける。でもレビューで「なぜこのstateはここにある?」と聞かれると、うまく言葉にできない。自分はstate管理をライブラリの話として捉えがちでしたが、調べてみると、その前に「何を覚えるか」「誰が持つか」「どこが正本か」を決める必要があると分かりました。この記事では、その判断をReactの小さな例で整理します。
TL;DR
- 計算できる値はstateにせず、保存する情報を最小限にする
- 共有するstateは、それを使うコンポーネントの最も近い共通親に置く
- React内のownerと、Server / URLなど「データの正本」は分けて考える
1. state管理は「ライブラリ選定」の前にある
ReduxやZustandを選ぶ前に、そもそもstateが何なのかを整理した方が話が早そうです。Reactの公式ドキュメントでは、stateはコンポーネントの「memory」として説明されています。
たとえばカウンターなら、画面が次のレンダーでも覚えておく必要があるのは count です。
function Counter() {
const [count, setCount] = useState(0)
return (
<button onClick={() => setCount(count + 1)}>
{count}
</button>
)
}
ここで大事なのは、count が単なる変数ではなく、「このUIが次のレンダーでも覚えておきたい値」だということでした。
React公式の State: A Component's Memory を読むと、stateはレンダーをまたいで保持され、stateの更新が再レンダーを引き起こすという整理になっています。
「stateを持つ」は何を意味するのか
記事内では「このコンポーネントがstateを持つ」と書きます。これは、その値をどのコンポーネントの責任として保持・更新するかという設計上の表現です。
function SearchInput() {
const [keyword, setKeyword] = useState("")
// ...
}
この場合は「SearchInput が keyword のowner」と考えます。
厳密には、Reactのstateはコンポーネントのコードそのものに格納されているわけではありません。Reactはstateをレンダーツリー上の位置と対応づけて管理しています。この記事では設計上の責任を説明するために「コンポーネントが持つ」という言い方をします。
ここまでなら useState の話です。ただ、実際の設計で迷うのは「この値をstateにするか」「どこに置くか」の方でした。
2. 問い1 — これは本当にstateか?
stateの置き場所を考える前に、不要なstateを消した方がシンプルになります。Reactの Thinking in React でも、まずUIが必要とする最小限のstateを特定する流れになっています。
fullName はstateにしなくていい
こんなコードを考えます。
const [firstName, setFirstName] = useState("Taro")
const [lastName, setLastName] = useState("Yamada")
const [fullName, setFullName] = useState("Taro Yamada")
fullName は firstName と lastName から計算できます。
const [firstName, setFirstName] = useState("Taro")
const [lastName, setLastName] = useState("Yamada")
const fullName = `${firstName} ${lastName}`
保存する値を減らすと、同期しなければならない値も減ります。
もし fullName までstateにすると、firstName を更新したときに fullName も忘れず更新する必要があります。つまり、stateを1個増やしたことで新しい同期処理も生まれます。
filteredProducts を保存すると何が起きるか
商品一覧でも同じです。
const [products, setProducts] = useState<Product[]>([])
const [filter, setFilter] = useState("")
const [filteredProducts, setFilteredProducts] = useState<Product[]>([])
filteredProducts が products と filter から毎回求められるなら、普通は保存しなくても作れます。
const [products, setProducts] = useState<Product[]>([])
const [filter, setFilter] = useState("")
const filteredProducts = products.filter(product =>
product.name.includes(filter)
)
Reactの Choosing the State Structure では、冗長なstateや重複したstateを避ける方針が示されています。調べていて腹落ちしたのは、これは単なる「きれいなコード」の話ではなく、同期対象を増やさないための設計だという点でした。
stateかどうか迷ったときの見方
自分の中では、次の3つを確認すると整理しやすくなりました。
| 確認すること | 当てはまるなら |
|---|---|
| 親からpropsとして受け取っている? | その子自身のstateではない |
| 既存のprops / stateから計算できる? | 保存せず計算する |
| ユーザー操作などで時間とともに変わる? | state候補になる |
これで「何を保存するか」が決まりました。残るのは、そのstateをどこが持つかです。
3. 問い2 — 誰がそのstateを持つのか?
ここが自分には一番分かりにくいところでした。「Input自身が持つ」「共通親が持つ」という言い方は、コンポーネントツリーを描くとかなり具体的になります。
親・子・共通親をツリーで見る
検索画面がこう分かれているとします。
function SearchPage() {
return (
<>
<SearchInput />
<SearchResult />
</>
)
}
コンポーネントの関係はこうです。
SearchPage は SearchInput と SearchResult の親です。
そして、SearchInput と SearchResult の両方から見たとき、最初に合流する上位コンポーネントが SearchPage です。これがこの記事でいう共通親です。
Inputだけが使うなら、Inputの近くに置く
たとえば入力欄のフォーカス状態を考えます。
function SearchInput() {
const [isFocused, setIsFocused] = useState(false)
return (
<input
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
/>
)
}
isFocused を SearchInput しか使わないなら、わざわざ SearchPage に上げる理由はありません。
「stateは上に置くほどよい」のではなく、必要な範囲に近い場所へ置く方が依存範囲を狭くできます。
兄弟が同じ値を使うなら共通親へ上げる
では keyword はどうでしょう。
SearchInput は現在の検索文字列を表示するために使います。SearchResult も、その文字列で検索結果を絞り込むために必要です。
もし keyword を SearchInput の中だけに置くと、こうなります。
兄弟である SearchResult は、SearchInput のローカルstateを直接読む設計にはなっていません。
そこで、keyword を両方の共通親である SearchPage に移します。
function SearchPage() {
const [keyword, setKeyword] = useState("")
return (
<>
<SearchInput
keyword={keyword}
onKeywordChange={setKeyword}
/>
<SearchResult keyword={keyword} />
</>
)
}
SearchInput はpropsとして値を受け取り、変更したいときは親から渡された関数を呼びます。
type SearchInputProps = {
keyword: string
onKeywordChange: (keyword: string) => void
}
function SearchInput({
keyword,
onKeywordChange,
}: SearchInputProps) {
return (
<input
value={keyword}
onChange={e => onKeywordChange(e.target.value)}
/>
)
}
構造はこうなりました。
子にあったstateを上位の共通親へ移すことを、Reactでは lifting state up と呼びます。Sharing State Between Components でも、複数コンポーネントを同期させるときは、stateを最も近い共通親に移す流れが紹介されています。
ここで「誰が値を書き換えるか」だけを見ないのもポイントでした。入力操作が起きるのは SearchInput ですが、keyword を必要としている範囲は SearchInput と SearchResult の両方です。だからownerは SearchPage になります。
Single Source of Truthは「全部global」ではない
keyword を SearchInput と SearchResult の両方に持たせる方法も、一見できそうです。
// SearchInput
const [keyword, setKeyword] = useState("React")
// SearchResult
const [keyword, setKeyword] = useState("React")
ただし、これは同じ意味の値が2つ存在する状態です。
片方だけ更新されたら、どちらが正しい keyword なのか分からなくなります。
Reactのドキュメントで出てくる Single Source of Truth は、「アプリのstateを全部1個のglobal storeに入れる」という意味ではありません。stateごとに、それを所有する場所を1つ決めるという考え方です。
「共有するならglobal store」と決め打ちすると、ドロップダウンの開閉のようなローカルな値までアプリ全体の依存になります。まずは最も近い共通親で足りるかを見る方が整理しやすいです。
Reduxの公式ドキュメントでも、すべてのstateをReduxへ入れる必要はなく、ローカルな値は最も近いUIコンポーネントに置く考え方が示されています。
4. 問い3 — 「owner」と「正本」は分けて考える
SearchPage が keyword のownerだと分かりました。ただ、「サーバーが本当の所有者」という表現まで混ざると、自分は同じ話に見えていました。
整理すると、これは別の軸でした。
React内のownerは「どのコンポーネントが保持・更新の責任を持つか」です。
一方、ここでいう正本は「そのデータの本物はどこにあるか」です。この2つを分けると、Local / Shared Client / Server / URLの違いが見やすくなりました。
Local State — そのUI周辺だけで使う
ドロップダウンの開閉や、入力途中の文字のように、特定のUI周辺だけで必要な値です。
function SortSelect() {
const [isOpen, setIsOpen] = useState(false)
// ...
}
他の画面が isOpen を必要としないなら、近くに置いたままで十分です。
Shared Client State — 離れたUIが共有する
ブラウザ側だけで意味を持ち、離れた複数の場所で共有したい値もあります。
たとえば、
- テーマ
- 現在選択中のワークスペース
- アプリ全体に影響するサイドバー状態
などです。
この範囲になるとContext、Redux、Zustandのような共有手段が候補になります。ただ、ここでも先に決めるのはライブラリではなく、「どこまでのUIがこの値を必要としているか」です。
Server State — ブラウザ側の値は原本ではない
APIから取得した products や users は、少し性質が違います。
商品の価格がブラウザに表示されていても、そのブラウザ内の値が唯一の正本ではありません。サーバー側で更新されれば、手元のデータは古くなります。
このためServer Stateには、ローカルstateではあまり出てこない問題があります。
| 論点 | 例 |
|---|---|
| キャッシュ | 取得済みデータを再利用する |
| stale | 手元のデータが古くなっていないか |
| refetch | いつ取り直すか |
| mutation後の同期 | 更新後に表示をどう合わせるか |
TanStack Queryのドキュメント を読むと、TanStack QueryはServer Stateを扱うライブラリとして、client state managerとは役割が違うと整理されています。
この区別を知ると、「APIの結果を全部Reduxに入れて自前で同期する」という設計が、なぜ大変になりやすいのかも見えやすくなりました。
URL State — 共有・復元したいならURLが自然な場合もある
商品一覧のページ番号やソート条件を考えます。
/products?page=3&sort=price
page=3 や sort=price をURLに持たせると、ブラウザの再読み込み後も状態が残ります。URLをそのまま共有でき、戻る・進むとも噛み合います。
React Routerでは useSearchParams を使ってsearch paramsを読み書きできます。
const [searchParams, setSearchParams] = useSearchParams()
const page = searchParams.get("page") ?? "1"
setSearchParams({
page: "3",
sort: "price",
})
React RouterのState Management でも、URL search paramsをstateの置き場所として扱う例が紹介されています。
useState に置けるかどうかだけで考えると見落としますが、「リロードや共有まで含めて、どこを正本にしたいか」で考えるとURLが自然なケースもあります。
5. 問い4 — stateは「置き場所」だけでなく「形」も設計する
ownerが正しくても、stateの中身に同じ情報のコピーや矛盾があれば、同期問題は残ります。Choosing the State Structure を読むと、stateは矛盾・冗長・重複を避ける方向で設計する考え方が繰り返し出てきます。
同じ情報をオブジェクトごと複製しない
商品一覧と選択中の商品を両方stateに持つ例です。
const [items, setItems] = useState([
{ id: 1, name: "Alice" },
{ id: 2, name: "Bob" },
])
const [selectedItem, setSelectedItem] = useState({
id: 1,
name: "Alice",
})
この形だと Alice の情報が2箇所にあります。
items 側だけ名前を更新すると、selectedItem に古い名前が残るかもしれません。
const [items, setItems] = useState([
{ id: 1, name: "Alice" },
{ id: 2, name: "Bob" },
])
const [selectedId, setSelectedId] = useState(1)
const selectedItem =
items.find(item => item.id === selectedId)
選択状態として覚えるのは id だけにして、本体は items から探します。これなら Alice の本体は1箇所です。
booleanが増えると「ありえない状態」も作れる
送信フォームをこう持つとします。
const [isLoading, setIsLoading] = useState(false)
const [isSuccess, setIsSuccess] = useState(false)
const [hasError, setHasError] = useState(false)
この3つは独立したbooleanなので、型の上ではこういう状態も作れます。
isLoading = true
isSuccess = true
hasError = true
もし実際の画面が「送信中」「成功」「失敗」のどれか1つしか取らないなら、1つの値にした方が自然です。
type Status =
| "idle"
| "submitting"
| "success"
| "error"
const [status, setStatus] = useState<Status>("idle")
状態の関係も見やすくなります。
ここではstate machineまで踏み込みません。ただ、booleanが増えてきたときに「本当は1つの状態名として表せないか」と考えるだけでも、矛盾をかなり減らせそうです。
6. 4つの質問でstateの置き場所を決める
個別の原則だけだと、実装中にどの順で考えるか迷います。商品検索画面を使って、一連の判断にまとめると再利用しやすくなりました。
ProductPage
├── SearchBox
├── SortSelect
├── ProductList
└── Pagination
候補は次の5つです。
| 値 | 役割 |
|---|---|
searchText |
検索文字列 |
sort |
並び順 |
page |
ページ番号 |
products |
APIから取得した商品 |
isSortMenuOpen |
ソートUIの開閉 |
自分なら、まずこの順で見ます。
この画面なら、たとえば次のように考えられます。
products
APIから取得する商品一覧なら、正本はサーバー側です。Server Stateとして扱うのが自然です。
isSortMenuOpen
SortSelect の表示だけに必要ならLocal Stateで足ります。
page と sort
再読み込みしても残したい、URLを共有したい、戻る・進むと連動させたいならURL Stateが合います。
searchText
ここは要件で変わります。検索欄と商品一覧の両方が使うだけなら ProductPage がownerになる形でも十分です。検索条件をURL共有したいならsearch paramsへ寄せる選択もできます。
こうして見ると、判断に使っているのは「ReduxかZustandか」ではありません。
- 本当に保存が必要か
- 誰が使うか
- 正本はどこか
- 重複や矛盾を作らない形か
コードレビューでも、stateが追加されたときにこの4つを聞けば、ライブラリ名より先に設計の意図を確認できます。
おわりに
state設計を調べる前は、useState、Context、ReduxのようなAPIの使い分けが中心だと思っていました。整理してみると、その手前にあるのは「このUIにとって正しい情報源はどこか」という問いでした。stateが増えたときほど、コードを足す前に情報の重複とownerを見る。この視点を持っていると、state管理ライブラリの選択もずっと理由のある判断になります。