1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Reduxを選ぶ前に知りたい、フロントエンドのstate設計 — 「何を」「誰が」「どこで」持つか

1
Posted at

はじめに

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("")
  // ...
}

この場合は「SearchInputkeyword の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")

fullNamefirstNamelastName から計算できます。

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[]>([])

filteredProductsproductsfilter から毎回求められるなら、普通は保存しなくても作れます。

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 />
    </>
  )
}

コンポーネントの関係はこうです。

SearchPageSearchInputSearchResult の親です。

そして、SearchInputSearchResult の両方から見たとき、最初に合流する上位コンポーネントが SearchPage です。これがこの記事でいう共通親です。

Inputだけが使うなら、Inputの近くに置く

たとえば入力欄のフォーカス状態を考えます。

function SearchInput() {
  const [isFocused, setIsFocused] = useState(false)

  return (
    <input
      onFocus={() => setIsFocused(true)}
      onBlur={() => setIsFocused(false)}
    />
  )
}

isFocusedSearchInput しか使わないなら、わざわざ SearchPage に上げる理由はありません。

「stateは上に置くほどよい」のではなく、必要な範囲に近い場所へ置く方が依存範囲を狭くできます。

兄弟が同じ値を使うなら共通親へ上げる

では keyword はどうでしょう。

SearchInput は現在の検索文字列を表示するために使います。SearchResult も、その文字列で検索結果を絞り込むために必要です。

もし keywordSearchInput の中だけに置くと、こうなります。

兄弟である 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 を必要としている範囲は SearchInputSearchResult の両方です。だからownerは SearchPage になります。

Single Source of Truthは「全部global」ではない

keywordSearchInputSearchResult の両方に持たせる方法も、一見できそうです。

// 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」と「正本」は分けて考える

SearchPagekeyword の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から取得した productsusers は、少し性質が違います。

商品の価格がブラウザに表示されていても、そのブラウザ内の値が唯一の正本ではありません。サーバー側で更新されれば、手元のデータは古くなります。

このため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=3sort=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で足ります。

pagesort

再読み込みしても残したい、URLを共有したい、戻る・進むと連動させたいならURL Stateが合います。

searchText

ここは要件で変わります。検索欄と商品一覧の両方が使うだけなら ProductPage がownerになる形でも十分です。検索条件をURL共有したいならsearch paramsへ寄せる選択もできます。

こうして見ると、判断に使っているのは「ReduxかZustandか」ではありません。

  • 本当に保存が必要か
  • 誰が使うか
  • 正本はどこか
  • 重複や矛盾を作らない形か

コードレビューでも、stateが追加されたときにこの4つを聞けば、ライブラリ名より先に設計の意図を確認できます。

おわりに

state設計を調べる前は、useState、Context、ReduxのようなAPIの使い分けが中心だと思っていました。整理してみると、その手前にあるのは「このUIにとって正しい情報源はどこか」という問いでした。stateが増えたときほど、コードを足す前に情報の重複とownerを見る。この視点を持っていると、state管理ライブラリの選択もずっと理由のある判断になります。

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?