Reactコンポーネントを書いていると、表示に必要な値をとりあえずstateとして持ちたくなることがあります。
たとえば、
- propsから受け取ったユーザー情報をもとに表示名を作る
- 検索キーワードをもとにフィルタリング済みのリストを作る
- 選択状態に応じて表示用のデータを作る
こうした値は一見stateとして管理したくなりますが、多くの場合それらは独立したstateではなく、propsや既存のstateから計算できるderived value(派生値)です。
derived valueをstateとして持つと、元データと表示用データの同期が必要になり、コードが複雑になります。
さらに、stateの位置を誤ると、不要に広い範囲で再レンダリングが発生する原因にもなります。
この記事では、Reactのstate設計でまず確認すべきポイントとして、「その値は本当にstateなのか?」という問いから、State ownership(状態の所有権)とレンダリング範囲の考え方を整理します。
UIはstateとpropsから計算される
Reactにおいて、UIはstateとpropsから計算される結果です。
そのため、コンポーネントを設計するときは、まず「どの値をstateとして持つか」だけでなく、「どの値をstateとして持たないか」を考える必要があります。
たとえば、次のようなコードを考えてみます。
type User = {
firstName: string;
lastName: string;
};
function UserProfile({ user }: { user: User }) {
const [fullName, setFullName] = useState(
`${user.lastName} ${user.firstName}`
);
return <p>{fullName}</p>;
}
一見すると自然に見えますが、このfullNameは独立したstateではありません。
user.lastNameとuser.firstNameから計算できる値です。
このような値をstateとして持つと、元のpropsと派生したstateの間でズレが発生する可能性があります。
たとえば、親コンポーネントから渡されるuserが変わっても、fullName stateは自動的に更新されません。
useStateの初期値は、初回レンダリング時にstateを初期化するための値であり、その後propsの変更に追従し続けるものではないからです。
この場合は、stateとして持つのではなく、レンダリング中に計算するほうがシンプルです。
type User = {
firstName: string;
lastName: string;
};
function UserProfile({ user }: { user: User }) {
const fullName = `${user.lastName} ${user.firstName}`;
return <p>{fullName}</p>;
}
この構造であれば、userが変わるたびにコンポーネントが再レンダリングされ、fullNameも最新のpropsから自然に再計算されます。
同期処理を書く必要もありません。
stateの不整合を心配する必要もありません。
derived value(派生値)はstateにしない
同じ考え方は、フィルタリングされたリストにも当てはまります。
次のように、検索キーワードに応じて商品一覧をフィルタリングするケースを考えます。
type Product = {
id: string;
name: string;
};
function ProductList({ products }: { products: Product[] }) {
const [keyword, setKeyword] = useState('');
const [filteredProducts, setFilteredProducts] = useState(products);
useEffect(() => {
setFilteredProducts(
products.filter((product) => product.name.includes(keyword))
);
}, [products, keyword]);
return (
<>
<input value={keyword} onChange={(e) => setKeyword(e.target.value)} />
{filteredProducts.map((product) => (
<p key={product.id}>{product.name}</p>
))}
</>
);
}
このコードは動作します。
しかし、構造としては少し複雑です。
filteredProductsは独立したデータではなく、productsとkeywordから計算できる値です。
そのため、別のstateとして管理すると、次のような問題が起きやすくなります。
-
productsとfilteredProductsの同期が必要になる -
useEffectが増える - stateの更新タイミングを考える必要が出る
- 元データと表示用データがズレる可能性がある
この場合も、レンダリング中に計算すれば十分です。
type Product = {
id: string;
name: string;
};
function ProductList({ products }: { products: Product[] }) {
const [keyword, setKeyword] = useState('');
const filteredProducts = products.filter((product) =>
product.name.includes(keyword)
);
return (
<>
<input value={keyword} onChange={(e) => setKeyword(e.target.value)} />
{filteredProducts.map((product) => (
<p key={product.id}>{product.name}</p>
))}
</>
);
}
このほうが、データの流れが明確です。
-
keywordはユーザー入力なのでstateとして持つ -
productsはpropsとして受け取る -
filteredProductsはproductsとkeywordから計算する
つまり、stateとして持つべき値と、計算すればよい値を分けています。
useMemoは「正しくするため」ではなく「最適化するため」に使う
フィルタリング処理の計算コストが高い場合は、useMemoを使うこともあります。
const filteredProducts = useMemo(() => {
return products.filter((product) => product.name.includes(keyword));
}, [products, keyword]);
ただし、ここで重要なのは、useMemoはコードを正しく動かすための仕組みではないということです。
useMemoは、レンダリング間で計算結果をキャッシュするための最適化手段です。
そのため、まずはuseMemoなしで正しく動く構造にするべきです。
そのうえで、計算コストが高い場合や、再計算を避けたい理由がある場合にuseMemoを検討します。
言い換えると、次のような順番で考えるのがよいと思います。
この値は本当にstateなのかを確認する
propsや既存のstateから計算できるなら、まずレンダリング中に計算する
計算コストが問題になる場合にuseMemoを検討する
useMemoがないと正しく動かないコードになっている場合は、最適化以前にstate設計を見直す必要があります。
stateの位置はレンダリング範囲に影響する
stateをどこに置くかは、レンダリング範囲にも影響します。
たとえば、次のようなコンポーネントを考えます。
function Page() {
const [keyword, setKeyword] = useState('');
return (
<>
<Header />
<SearchBox value={keyword} onChange={setKeyword} />
<HeavyContent />
</>
);
}
この構造では、keywordが更新されるたびにPageが再レンダリングされます。
そして、Pageの中で Header、SearchBox、HeavyContentが再び評価されます。
もちろん、再レンダリングされたからといって、必ずしもDOMがすべて書き換えられるわけではありません。
Reactはレンダリング結果をもとに、必要な変更だけをDOMに反映します。
しかし、keywordがSearchBoxの中でしか使われない値であれば、そもそもPageに置く必要はありません。
その場合は、stateをSearchBoxの中に移動できます。
function Page() {
return (
<>
<Header />
<SearchBox />
<HeavyContent />
</>
);
}
function SearchBox() {
const [keyword, setKeyword] = useState('');
return (
<input value={keyword} onChange={(e) => setKeyword(e.target.value)} />
);
}
このようにすると、入力値の変更による影響範囲をSearchBoxの中に閉じ込めることができます。
不要な再レンダリングを減らしたいとき、すぐにmemoやuseCallbackを使いたくなることがあります。
しかし、その前に確認すべきなのは、stateの所有者が適切かどうかです。
State ownership(状態の所有権)を考える
State ownershipとは、「そのstateをどのコンポーネントが責任を持って管理するべきか」という考え方です。
stateの置き場所を決めるときは、次のように考えると整理しやすくなります。
そのコンポーネントだけで使う値か
その値が特定のコンポーネントの中だけで使われるなら、そのコンポーネントにstateを置くのが自然です。
function SearchBox() {
const [keyword, setKeyword] = useState('');
return (
<input value={keyword} onChange={(e) => setKeyword(e.target.value)} />
);
}
このkeywordがSearchBoxの外で使われないなら、親コンポーネントに持ち上げる必要はありません。
複数の子コンポーネントで共有する値か
複数のコンポーネントで同じstateを使う必要がある場合は、共通の親にstateを置くことを検討します。
function ProductPage({ products }: { products: Product[] }) {
const [keyword, setKeyword] = useState('');
const filteredProducts = products.filter((product) =>
product.name.includes(keyword)
);
return (
<>
<SearchBox value={keyword} onChange={setKeyword} />
<ProductCount count={filteredProducts.length} />
<ProductResults products={filteredProducts} />
</>
);
}
この場合、keywordはSearchBoxだけでなく、検索結果や件数表示にも影響します。
そのため、共通の親であるProductPageがstateを持つのは自然です。
propsやstateから計算できる値ではないか
propsや既存のstateから計算できる値であれば、基本的にはstateとして持たないほうがよいです。
const filteredProducts = products.filter((product) =>
product.name.includes(keyword)
);
これはstateではなく、derived valueです。
stateにする必要があるのは、ユーザー操作や外部イベントによって独立して変化する値です。
一方、他の値から導ける値は、可能な限り計算で表現するほうがシンプルになります。
useEffectで同期している値は疑う
Reactを書いていると、次のようなuseEffectを書きたくなることがあります。
const [fullName, setFullName] = useState('');
useEffect(() => {
setFullName(`${user.lastName} ${user.firstName}`);
}, [user.lastName, user.firstName]);
しかし、このようなコードは多くの場合不要です。
fullNameはuser.lastNameとuser.firstNameから計算できるため、レンダリング中に計算できます。
const fullName = `${user.lastName} ${user.firstName}`;
useEffectは、Reactの外部にあるシステムと同期するために使うものです。
たとえば、次のようなケースです。
- DOM APIを直接扱う
- 外部ライブラリと同期する
- WebSocket接続を管理する
- ブラウザAPIと連携する
一方で、React内部のpropsやstateから計算できる値を同期するためにuseEffectを使うと、コードが複雑になりやすくなります。
「propsやstateから計算できる値を、別のstateに入れてuseEffectで同期している」場合は、一度立ち止まって考える価値があります。
その値は、本当にstateとして持つ必要があるのでしょうか。
state設計のチェックリスト
Reactコンポーネントを書くときは、次の問いを確認するとstate設計を整理しやすくなります。
- この値は本当にstateなのか?
- propsや既存のstateから計算できる値ではないか?
- このstateの所有者は本当にこのコンポーネントなのか?
- そのstateは複数のコンポーネントで共有する必要があるのか?
- stateを必要以上に上位のコンポーネントに置いていないか?
-
useEffectで同期している値は、実はレンダリング中に計算できる値ではないか? -
useMemoを正しさのために使っていないか? - 最適化APIを使う前に、stateの位置を見直したか?
これらを確認するだけでも、不要なstateや不要な同期処理を減らせます。
まとめ
Reactをうまく使うということは、Hookをたくさん知っていることではありません。
もちろん、useState、useMemo、useCallback、memoなどのAPIを理解することは重要です。
しかし、それ以上に重要なのはstateとrenderの関係を理解し、どの値をstateとして持つべきかを判断できることです。
良いReactコードは「何をstateとして持つか」だけでなく、「何をstateとして持たないか」を慎重に判断します。
特に次の3つは、Reactコンポーネントをシンプルに保つうえで重要です。
- State ownership(状態の所有権)を明確にする
- derived value(派生値)を不要にstate化しない
- state変更の影響範囲を小さく保つ
不要な複雑さを減らす第一歩は、最適化APIを追加することではありません。
まず、「その値、本当にstateですか?」と疑うことです。