1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Reactの練習にライフプランシミュレーターを作ってみる

1
Posted at

はじめに

React + TypeScript の練習として、ライフプランシミュレーターを作りました。老後の資産推移を可視化するWebアプリで、いわゆる FIRE(Financial Independence, Retire Early) のシミュレーターとしても使えます。

「いくら貯めれば何歳で引退できるのか」「運用がうまくいかなかった場合でも破綻しないか」——そういった疑問を手軽に試せるアプリです。

実装や本記事の作成には Claude(Anthropic の AI)を活用しています。コードレビュー、リファクタリング案の提示、型定義の設計など、ほぼすべてのフェーズでペアプロ的に使いました。「AIと一緒に作ってみた」という視点でも読んでもらえると嬉しいです。


作ったもの

image.png

デプロイ先

Vercel でホスティングしています。

https://lifeplan-sim.vercel.app/

ソースコード: https://github.com/tkmz-n/lifeplan_sim


機能紹介

1. 資産推移グラフ(確定的シミュレーション)

image.png

現金と運用資産を積み上げグラフで表示します。現役中は収入 - 支出 = 貯蓄が積み上がり、退職後は運用資産を取り崩していく様子が一目でわかります。退職予定年齢には赤い点線の基準線が引かれます。

2. モンテカルロシミュレーション(1000回試行)

image.png

ここがこのアプリのメインです。運用リターンは毎年一定ではなく、上振れ・下振れします。その不確実性を1000回のシミュレーションで表現します。

グラフには以下のパーセンタイル帯を表示します:

意味
5〜95% 広い不確実性の範囲
25〜75% 現実的な想定範囲
中央値(太線) 最も代表的なシナリオ

3. サマリーカード

image.png

3つの数値をひと目で確認できます:

  • 破産確率:100歳時点で資産がマイナスになるシミュレーションの割合
  • 資産枯渇年齢(中央値):中央値シナリオで資産が尽きる年齢
  • 退職時資産(中央値):退職年齢時点での資産の中央値

4. ライフイベント設定

image.png

「45歳で住宅購入 -2000万円」「60歳で退職金 +1500万円」など、特定年齢での一時的な収支を自由に追加できます。イベント名・年齢・金額を入力するだけで、シミュレーション結果にリアルタイムで反映されます。


技術スタック

カテゴリ 技術
フレームワーク React 19 + TypeScript
ビルドツール Vite
UIライブラリ MUI (Material UI) v6
グラフ Recharts
デプロイ Vercel

実装のポイント

Box-Muller法で正規分布の乱数を生成

モンテカルロシミュレーションでは、毎年の運用リターンを「平均=期待リターン、標準偏差=ボラティリティ」の正規分布に従う乱数として生成します。JavaScriptの Math.random() は一様乱数なので、Box-Muller変換で正規分布に変換しています。

// Box-Muller法で正規分布の乱数を生成
function randomNormal(mean: number, stdDev: number): number {
  const u1 = Math.random()
  const u2 = Math.random()
  const z = Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2)
  return mean + stdDev * z
}

これを使って、退職前・退職後それぞれの毎年の資産増減を計算します:

// ランダムな年間リターンを生成(正規分布)
const annualReturn = randomNormal(returnRate, volatility)

if (age < retireAge) {
  investment *= (1 + annualReturn / 100)
  // ... 貯蓄の積み立て
} else {
  investment *= (1 + annualReturn / 100)
  // ... 生活費の取り崩し
}

useMemo でモンテカルロの再計算を制御

1000回シミュレーションは計算コストが高いため、useMemo でキャッシュします。ここで一工夫あって、ライフイベントの「名前」変更や「イベント追加ボタン押下直後(金額が 0 の状態)では再計算しないようにしています。

// ラベルを除き、かつ金額が0のイベントはスキップしてキー化
const lifeEventsSimKey = lifeEvents
  .filter(e => e.amount !== 0)
  .map(e => `${e.age}:${e.amount}`)
  .join(',')

const monteCarloResult = useMemo(() => {
  return calculateMonteCarloData()
}, [
  currentAge, retireAge, currentAssets, annualIncome, annualExpense,
  annualExpenseAfterRetire, initialInvestment, returnRate, volatility,
  savingsToInvestRatio, lifeEventsSimKey  // ← idやlabelは含めない
])

lifeEvents オブジェクトをそのまま依存配列に入れると、ラベル変更のたびに1000回計算が走ってしまいます。ageamount のみを文字列化して依存させることで、不要な再計算を防いでいます。

Recharts でパーセンタイル帯グラフを描く

モンテカルロの結果を「帯グラフ」で表現するため、ComposedChartArea を積み上げて使っています。Recharts には「帯」の概念がないため、差分を積み上げるというアプローチで実装しました。

// データを帯グラフ用に変換(差分を積み上げる)
const transformedData = data.map(d => ({
  age: d.age,
  base: d.p5,             // ベースライン(透明で表示)
  band1: d.p25 - d.p5,    // 5〜25% の高さ
  band2: d.p75 - d.p25,   // 25〜75% の高さ
  band3: d.p95 - d.p75,   // 75〜95% の高さ
  p50: d.p50              // 中央値(Lineとして表示)
}))
{/* ベースライン(透明、積み上げの基準) */}
<Area dataKey="base" stackId="1" fill="transparent" stroke="none" />

{/* 5〜25% と 75〜95%(薄い色) */}
<Area dataKey="band1" stackId="1" fill="#8884d8" fillOpacity={0.2} stroke="none" />
<Area dataKey="band2" stackId="1" fill="#8884d8" fillOpacity={0.4} stroke="none" />
<Area dataKey="band3" stackId="1" fill="#8884d8" fillOpacity={0.2} stroke="none" />

{/* 中央値の線 */}
<Line dataKey="p50" stroke="#333" strokeWidth={2} dot={false} />

stackId を揃えることで積み上げが実現し、透明なベースライン + 差分の積み上げで「任意の範囲を塗る」表現ができます。

MUI でレスポンシブ 2 カラムレイアウト

パラメータパネル(左)とグラフ(右)を lg ブレークポイント以上で横並び、それ以下で縦積みにしています。MUI の sx prop の flexDirection でブレークポイントごとの値を指定するだけで実現できます。

<Box sx={{
  display: 'flex',
  gap: 3,
  flexDirection: { xs: 'column', lg: 'row' },  // lgで横並び
  alignItems: 'flex-start'
}}>
  {/* 左: パラメータパネル(固定幅) */}
  <Paper sx={{ width: { xs: '100%', lg: '340px' }, flexShrink: 0 }}>
    ...
  </Paper>

  {/* 右: グラフ(残りスペースを使う) */}
  <Box sx={{ flex: 1, minWidth: 0 }}>
    ...
  </Box>
</Box>

minWidth: 0 を設定しているのは、flexboxで子要素がコンテナをはみ出す問題を防ぐためです。


Vercel へのデプロイ

Vite + React の構成は Vercel との相性が抜群で、デプロイは数分で完了しました。

手順はこれだけです:

  1. GitHub にリポジトリを push
  2. Vercel で「Import Project」からリポジトリを選択
  3. Build Command: npm run build、Output Directory: dist を確認(自動検出されます)
  4. Deploy ボタンを押す

以降は main ブランチへの push で自動デプロイされます。フロントエンドのみの静的サイトなら、本当にこれだけで公開できます。


まとめ

  • モンテカルロシミュレーション(Box-Muller + 1000試行)で運用の不確実性を可視化できました
  • useMemo の依存配列を工夫することで、無駄な再計算を抑えられました
  • Recharts の積み上げ Area を使ったパーセンタイル帯グラフは、少しトリッキーですが実現できました
  • MUI のブレークポイント対応でレスポンシブレイアウトが簡単に書けました

FIREを目指している方も、そうでない方も、老後のお金を可視化するツールとして使ってみてください。パラメータを変えながらいじるだけでもなかなか楽しいです。


参考

1
1
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
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?