元ゲームデザイナーが家族のためウェブサイトを作った話(2)
一言結論:画面を3タブに分け、サーバ状態はTanStack Queryに寄せたことで、UXと実装コストの両方が改善しました。
これは、自作の市場分析ダッシュボード(MarketRadar)を、vanilla JSの単一ページ構成(機能を増築していくスタイル)から React + TypeScript + Vite のSPAに作り直したときのメモです。
この記事で分かること
- ダッシュボードが大きくなると「単一ページ構成」がつらくなるポイント(UXの課題3つ)
- 3タブ(Overview / News / Analysis)で情報構造を整理した方法
- TanStack Queryとは何か / なぜ入れたか / このプロジェクトでの使い方
- Yahooデータ取得を「ブラウザ58回 → 1回の一括」に寄せた考え方
-
apiClient.tsでAPI境界を固定して、将来のサーバ移行を“差し替え”に寄せる方法
対象読者
- 個人開発でデータ系ダッシュボードやAI分析画面を作っている人
-
useStateで非同期データを扱って、状態管理がつらくなってきた人
使用技術
- React + TypeScript(strict) + Vite
- Tailwind CSS + Radix UI
- TanStack Query
- Vercel Serverless Functions(+ KV)
1. 「ダッシュボードなら単一ページで十分では?」から始まった
最初は「ダッシュボードなら単一ページ(またはvanilla JS)で十分」と思っていました。
ただ、機能が増えて画面が縦に伸びてくると、次の3つがずっと気になりました。
- 視認性の限界:情報が上下に長く並び、一目で把握しづらい
- 情報の分断:ニュースが「プロンプト材料」になっていてUIで活用できていない
- スクロールが長い:AI分析結果が画面下にあり、結果を見るだけで毎回スクロールが必要
ここで「画面を分ける」こと自体が価値になり、SPAに移行する判断が固まりました。
(改修前)情報が縦に伸びて、AI分析が下に押し出されていました。

2. 何をどう変えたか(決めたこと)
2.1 3タブ構成に再設計
- Dashboard(Overview): 一目で見る要約
- News: RSSニュース閲覧
- Analysis: AI分析結果 + 参考指標
このくらい画面単位が分かれるなら、画面遷移のあるSPA(React)の方が自然だと判断しました。
2.2 Tailwind CSS + Radix UI
- Tailwind: CSSを手書きする時間を減らし、生産性を上げる
- Radix: TabsなどのUIプリミティブをアクセシブルに早く作る
2.3 状態管理の方針(サーバ状態 / クライアント状態)
- サーバ状態:TanStack Queryで非同期データ(loading/error/cache)を標準化
-
クライアント状態:いま必要なのはタブ/フィルタ程度 →
useStateで十分、Zustandは保留
「1画面に全部入れる」方針をやめて、Dashboardは 視認性と要約に寄せました。
React移行時点のアーキテクチャ(フォルダ構成)
UIの話に入る前に、移行時点の全体像をCLIっぽく置いておきます。
marketradar/
├─ api/ # Vercel Serverless Functions(CORS/Proxy/Rate limit)
│ ├─ yahoo.js # ★ GET /api/yahoo?batch=...(一括取得 + 正規化)
│ ├─ news.js # ★ GET /api/news(RSS収集 + 正規化)
│ └─ gemini.js # ★ POST /api/gemini(Rate limit/timeout)
├─ lib/ # Pure functions(テストしやすい)
│ ├─ indicators.js # RSI/MACD/SMA etc.
│ └─ parseRSS.js # RSSパース
├─ src/ # React SPA
│ ├─ main.tsx # ReactDOM + QueryClientProvider
│ ├─ App.tsx # 3 Tabs: Dashboard / News / Analysis
│ ├─ lib/
│ │ ├─ apiClient.ts # ★ API境界(ここだけ触ればサーバ移行しやすい)
│ │ ├─ constants.ts # tickers/period map/thresholds
│ │ ├─ buildDataBlock.ts # Gemini用のデータブロック生成
│ │ └─ geminiPrompts.ts # プロンプトテンプレート
│ ├─ hooks/
│ │ ├─ useMarketData.ts # useQuery(ticker, period)
│ │ ├─ useAllMarketData.ts # useQueries(8 tickers × periods)
│ │ ├─ useNews.ts # useQuery(news)
│ │ └─ useGemini.ts # useMutation(POST /api/gemini)
│ └─ components/
│ ├─ dashboard/ # cards/chart/period selector
│ ├─ news/ # list/item
│ └─ analysis/ # gemini panel/technical table/correlation
├─ tests/ # Vitest
├─ index.html # Vite entry
├─ package.json
└─ vite.config.ts
(アーキテクチャ)
フロントの apiClient.ts は呼び出し側(HTTPクライアント)、Vercel Functions(/api/*)は受け口(APIエンドポイント)です。

ポイントは2つです。
- サーバ(api/)は薄く:CORS回避、上流取得、レスポンスの正規化、キャッシュ寄りの責務だけ
- フロント(src/)が主役:指標計算・相関・プロンプト生成など “プロダクトの価値” はブラウザ側に置く
3. TanStack Queryを入れた理由
3.1 TanStack Queryとは何か
(:contentReference[oaicite:0]{index=0}公式サイトより)
"Powerful asynchronous state management, server-state utilities and data fetching"
(強力な非同期状態管理と、サーバー状態を扱うためのユーティリティおよびデータフェッチ機能)
TanStack Queryは、サーバー状態の取得・キャッシュ・同期を一元管理する仕組みを提供します。
TanStack Queryは、サーバーから取得するデータ(サーバー状態)を管理するためのライブラリです。
データの取得・キャッシュ・再取得(refetch)・ローディング/エラー状態の管理をまとめて扱えるのが特徴で、useState や useEffect だけで実装すると複雑になりがちな処理をシンプルに書けるようになります。
3.2 useState だけだとつらくなる
ダッシュボードはAPIデータが中心なので、useState だけで頑張ると次が増えがちです。
- ローディング/エラーUIの分岐が増える
- refetch(再取得)タイミングが散らばる
- タブ移動や再描画で “取り直し” が起きやすい
- キャッシュを自前でやり始めると複雑になる
ここをライブラリに寄せたくて、TanStack Queryを入れました。
3.3 どう使ってるか(queryKey / staleTime)
このプロジェクトでは staleTime: 60_000(60秒) を基本にしています。
データが秒単位で更新される用途ではないので、UXとAPI負荷のバランスを取った設定です。
useQuery({
queryKey: ['yahoo', ticker, period],
queryFn: () => fetchYahoo(ticker, range, interval),
staleTime: 60_000,
retry: 2,
});
3.4 採用しなかった選択肢
- useEffect + useState + 自前キャッシュでも可能ですが、保守費用↑
- Zustandは「クライアント状態」が大きくなる時に導入予定のため保留
4. 3タブ化で何が変わったか
4.1 Dashboardタブ
(Dashboard)「1画面に全部入れる」方針をやめて、Dashboardは 視認性と要約に寄せました。

4.2 Analysisタブ
AI分析結果を専用タブに移動。
“結果を見るためにスクロールする”問題が大幅に減り、参考指標もここに集約しました。
4.3 Newsタブ
RSSで集めたニュースを プロンプト材料ではなく ユーザーが見る情報として昇格。
4.4 Footer
(Footer)作者情報とフィードバック窓口を追加しました。

5. React移行と一緒に整えたこと(性能/信頼性)
5.1 Yahoo API取得の最適化:「ブラウザ58回 → 1回の一括取得」
8銘柄×複数期間のチャートを同時に読み込む都合で、結果的に約58回のリクエストになっていました。
ブラウザからYahooへ50回以上のリクエストを投げる構成は、運用面で不安がありました。
- ユーザー増加時のIPブロック/失敗率
- 初期ロードの体感劣化
- ネットワーク失敗のユーザー体験悪化
そこで、Vercel側で一括リクエストを受け、内部で並列処理して、ブラウザは “まとめて1回” だけ受け取るように変更しました。
Before / After
- Before: ブラウザ → Yahoo API(58回)
- After: ブラウザ → Vercel(1回)→ Yahoo(Vercel側で58回並列)
① バッチ文字列を作る(ticker,range,interval を | で結合)
// src/lib/makeBatch.ts
type BatchItem = { ticker: string; range: string; interval: string };
export function makeBatch(items: BatchItem[]) {
return items.map((it) => `${it.ticker},${it.range},${it.interval}`).join('|');
}
② apiClient.ts 経由で batch を呼ぶ(API境界を固定)
// src/lib/apiClient.ts
const BASE = import.meta.env.VITE_API_BASE ?? '';
export async function fetchYahooBatch(batch: string) {
const res = await fetch(`${BASE}/api/yahoo?batch=${encodeURIComponent(batch)}`);
if (!res.ok) throw new Error(`Yahoo API error ${res.status}`);
return res.json();
}
③ TanStack Queryで一括取得(queryKey設計 + staleTime)
// src/hooks/useYahooBatch.ts
import { useQuery } from '@tanstack/react-query';
import { fetchYahooBatch } from '../lib/apiClient';
import { makeBatch } from '../lib/makeBatch';
type BatchItem = { ticker: string; range: string; interval: string };
export function useYahooBatch(items: BatchItem[]) {
const batch = makeBatch(items);
return useQuery({
queryKey: ['yahoo-batch', batch],
queryFn: () => fetchYahooBatch(batch),
staleTime: 60_000,
retry: 2,
});
}
- batch文字列を queryKey に含めることで、同じ条件の取得はキャッシュが効きます
- 逆に条件が変われば別キーになるので、取り回しがシンプルです
5.2 TypeScript + Tailwind CSSを入れた理由
- TypeScript: フロント/バック間のデータ契約を明確にし、将来的なSpring Boot移行のリスクを下げる
- Tailwind: Utility-firstでUI実装のスピードを上げる(デザインの負担を減らせた)
5.3 運用判断:rate limitとfail-open/fail-closed
Vercel KV(Redis)に障害が起きたとき、rate limitが効かない可能性があります。
そのとき「鍵が壊れたからサービスも止める」のは本末転倒だと思い、KV障害時の挙動はログを残しつつ全体を落とさない方向で調整しました。
5.4 504/timeout のトラブルシュート
症状
- 応答が10秒制限にかかり 504 が発生
原因候補
- プロンプトが重い(トークン過多)
- モデル応答時間の増加
対応
- プロンプト軽量化(キー短縮、ニュース長制限)
- gemini-2.5-flash → gemini-2.5-flash-lite に調整
- サーバ側のtimeout戦略を調整
結果
- 応答速度改善(10秒+ → 約6秒)
- 無料枠の制限内でも安定して動くようになった
apiClient.ts` を作った理由:将来のサーバ移行を“差し替え”にする
フロントからAPIを叩くコードが各所に散らばると、将来的にサーバを差し替えるとき全部直す羽目になります。
なので **「APIを叩くのは src/lib/apiClient.ts だけ」**というルールにしました。
News / Gemini も同様に集約します。
// src/lib/apiClient.ts(抜粋)
export async function fetchNews() {
const res = await fetch(`${BASE}/api/news`);
if (!res.ok) throw new Error(`News API error ${res.status}`);
return res.json();
}
export async function postGemini(prompt: string) {
const res = await fetch(`${BASE}/api/gemini`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prompt }),
});
if (!res.ok) throw new Error(`Gemini API error ${res.status}`);
return res.json();
}
これで将来、Vercel → Spring Bootに移行しても、基本的には apiClientの実装(や base URL)を差し替えるだけで済むようにしてます。
6. 今後:Vercel依存を段階的に減らす
Vercelは便利ですが、少し深掘りするとコストが上がりやすい。
そのため長期的にはSpring Bootでサーバを自前化して、Vercelを段階的に置き換える計画です。
まとめ
- ダッシュボードは「全部見せる」より「構造化して一目で見せる」が効く
-
useStateだけでサーバデータを扱うと loading/error/cache が散らかる → TanStack Queryが相性良かった - timeout/rate limit は機能より先にUXを壊すので、早めの対処が効いた



