フルスタックエンジニアのMrFireです。今回は状態管理に注目してみたいと思います。
フロントエンド開発のプロジェクトでは、「状態管理」の仕組みが必ず何かしら取り入れられているものです。画面上のいろんなコンポーネントの状態もあれば、コード上の変数、対APIからの通信状態などなど、いろいろな「状態」を、これまた色々な方法で「管理」されています。
特にReactやVueを使っているのであれば、有名なライブラリを使った柔軟な状態管理ができるのですが、一方で「本来(ブラウザ側で)管理する必要のない情報までクライアント側に抱え込んでいる」ケースが非常に増えました。結果として、ロジックは肥大化し、状態の整合性が崩れ、バグ修正に追われる日々が続く――そんな経験をした開発者は少なくないはずです。
この記事では、私たちが実際の案件で何度も直面した課題を整理しながら、状態をURLとサーバへ戻すことで得られる「設計の軽さ」を紹介します。
3つの具体的なパターンをもとに、Bad/Goodパターンの形でご紹介したいと思います。
① クライアント(フロントエンド側)が抱えすぎる状態管理
ReactのSPA開発では、useState
や useReducer
を気軽に使えるため、UIの状態をすべてクライアント側で持ってしまうことがよくあります。
最初は小規模で問題ないように見えても、機能追加が進むと 「状態の依存関係が爆発」 し、どこで何が変更されたのか分からなくなります。
その結果、リロード時に状態が消えたり、URLを共有しても同じ画面が再現できなかったりといったユーザー体験上の問題に繋がります。
Bad Case:すべての状態をuseStateで保持
function ProductList() {
const [filter, setFilter] = useState('all');
const [sort, setSort] = useState('price');
const [page, setPage] = useState(1);
const [products, setProducts] = useState([]);
useEffect(() => {
fetch(`/api/products?filter=${filter}&sort=${sort}&page=${page}`)
.then(res => res.json())
.then(setProducts);
}, [filter, sort, page]);
return (...);
}
この構成では、コンポーネントの内部にすべての状態を抱えています。
ページをリロードすれば選択情報が消え、URLを共有しても相手には反映されません。ユーザーにとって「戻る」「再読み込み」がストレスになる典型的な設計です。
Good Case:URLパラメータを“単一の真実”に
import { useSearchParams } from "next/navigation";
function ProductList() {
const search = useSearchParams();
const filter = search.get('filter') ?? 'all';
const sort = search.get('sort') ?? 'price';
const page = search.get('page') ?? '1';
const { data: products } = useSWR(`/api/products?${search.toString()}`);
return (...);
}
URLを唯一の「状態のソース」として扱えば、アプリの整合性は劇的に高まります。
リロードや共有にも強く、URLの変更だけでアプリ状態を再構築できるため、状態の再現性が保証されます。Next.jsやRemixのようなフレームワークではこの設計を前提としており、UIロジックを非常にシンプルに保てます。
② UIでしか使わないデータをグローバル化してしまう
もう一つのよくある問題は、UI限定の一時的な状態をグローバルストアに入れてしまうことです。
モーダルの開閉やタブ選択など、ページ単位で完結する情報をアプリ全体で共有してしまうと、責務の境界が曖昧になります。結果、複数ページやモジュールで思わぬ干渉が起こり、バグ修正が難しくなります。
Bad Case:UI状態をグローバルストアに保存
const useStore = create((set) => ({
isModalOpen: false,
setModalOpen: (v) => set({ isModalOpen: v })
}));
function Page() {
const { isModalOpen, setModalOpen } = useStore();
return (
<>
<button onClick={() => setModalOpen(true)}>Open</button>
{isModalOpen && <Modal onClose={() => setModalOpen(false)} />}
</>
);
}
グローバルストアでUI状態を管理してしまうと、一見どこからでも操作できて便利ですが、実際には状態のスコープが無限に広がり、依存の連鎖が起こります。
特に複数のモーダルを扱う場合、意図せず他の画面が影響を受けることがあります。
Good Case:UI内で完結させる
function Modal({ onClose }) {
useEffect(() => {
const onKeyDown = (e) => e.key === "Escape" && onClose();
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [onClose]);
return (
<dialog open>
<p>モーダル内容</p>
<button onClick={onClose}>閉じる</button>
</dialog>
);
}
UIの中で状態を閉じることで、コンポーネント単位で独立したライフサイクルを持てます。スコープ外への影響を防ぎ、状態の破棄も自然に行われます。
「UIの責務はUIで完結させる」という原則を守るだけで、アプリ全体の安定性が大きく向上します。
③ サーバーで計算すべきロジックをクライアントで処理している
最近のフロントエンドは、高速な通信やキャッシュ技術の進化により、サーバー主導の設計に回帰しつつあります(と感じるプロジェクトが増えてます)。
それでも、依然として「何でもクライアントで処理する方が速い」という誤解が残っています。特に集計処理やフィルタリングなど、本来はサーバーでやるべき部分をフロントで行うと、データ量の増加とともにパフォーマンスが悪化します。
Bad Case:クライアントがすべて再計算
const [items, setItems] = useState([]);
const total = items.reduce((sum, item) => sum + item.price, 0);
APIから取得した全データを都度計算しているパターンです。
最初は軽くても、データが数千件に増えるとフレーム落ちやスクロールのカクつきが発生します。ユーザー体験の品質を損なう原因です。
Good Case:サーバーサイドで集計し、結果のみ返す
export async function getTotal() {
const result = await db.query("SELECT SUM(price) as total FROM items");
return result[0].total;
}
サーバーが集計を行い、UIは結果を受け取って描画するだけに専念します。
この「役割の分離」こそがパフォーマンスと保守性の両立の鍵です。Next.jsのServer ComponentsやLaravel Livewireなどは、この構造をフレームワークレベルでサポートしています。
まとめ
状態管理は「持つ」ことより「手放す」ことが難しい分野です。
状態を減らすというのは削除ではなく、「どこに置くのが正しいか」を見極めることです。
- URLは、状態の再現性を保証する場所。
- UIは、見た目と体験を担う場所。
- サーバーは、整合性と真実を保証する場所。
この三層が正しく連携すれば、アプリはシンプルで壊れにくくなります。「過剰な状態管理」をやめることは、チームのメンテナンス効率を上げ、ユーザー体験をより安定させる第一歩です。
株式会社ファストコーディングでは、このような“サーバ主導の最小ステート設計”をベースに、長期的に運用できるWebアプリケーション開発を支援しています。ご興味がある方はぜひいつでもご相談ください。