DEV Community

Robert Pringle
Robert Pringle

Posted on

Build a Mortgage Calculator in React in 10 Minutes

Mortgage calculators are one of those "simple" features that turn out to be tricky — rounding matters, and users expect the numbers to match their bank's quote exactly.

This tutorial skips the math and uses a financial API to build a polished React mortgage calculator with an amortization breakdown.

What We're Building

A mortgage calculator that shows:

  • Monthly payment
  • Total cost of the loan
  • Total interest paid
  • Full month-by-month payment breakdown

Live example (you'll have this running in 10 minutes):

Loan amount:  $350,000
Rate:         6.75%
Term:         30 years

Monthly payment:  $2,270.17
Total paid:       $817,261.20
Total interest:   $467,261.20
Enter fullscreen mode Exit fullscreen mode

Setup

npx create-react-app mortgage-calc
cd mortgage-calc
npm start
Enter fullscreen mode Exit fullscreen mode

Get a free API key at RapidAPI.

Add to .env:

REACT_APP_RAPIDAPI_KEY=your_key_here
Enter fullscreen mode Exit fullscreen mode

The API Hook

Create src/hooks/useMortgage.js:

import { useState, useCallback } from 'react';

const API_HOST = 'fincalcapi.p.rapidapi.com';
const API_KEY = process.env.REACT_APP_RAPIDAPI_KEY;

export function useMortgage() {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState(null);

  const calculate = useCallback(async ({ principal, annualRate, termYears }) => {
    setLoading(true);
    setError(null);

    try {
      // Fetch mortgage summary and amortization schedule in parallel
      const [mortgageRes, amortizeRes] = await Promise.all([
        fetch(
          `https://${API_HOST}/mortgage?` +
          new URLSearchParams({ principal, annual_rate: annualRate, term_years: termYears }),
          { headers: { 'X-RapidAPI-Key': API_KEY, 'X-RapidAPI-Host': API_HOST } }
        ),
        fetch(
          `https://${API_HOST}/amortize?` +
          new URLSearchParams({
            principal,
            annual_rate: annualRate,
            term_months: termYears * 12,
          }),
          { headers: { 'X-RapidAPI-Key': API_KEY, 'X-RapidAPI-Host': API_HOST } }
        ),
      ]);

      const [mortgage, amortize] = await Promise.all([
        mortgageRes.json(),
        amortizeRes.json(),
      ]);

      setData({ ...mortgage, schedule: amortize.schedule });
    } catch (err) {
      setError(err.message);
    } finally {
      setLoading(false);
    }
  }, []);

  return { data, loading, error, calculate };
}
Enter fullscreen mode Exit fullscreen mode

★ Insight ─────────────────────────────────────
Using Promise.all() here fires both API requests simultaneously instead of sequentially — this halves the perceived latency when both calls take ~50ms each.
─────────────────────────────────────────────────

The Calculator Form

Create src/components/MortgageForm.jsx:

import { useState } from 'react';

export function MortgageForm({ onCalculate, loading }) {
  const [values, setValues] = useState({
    principal: 350000,
    annualRate: 6.75,
    termYears: 30,
  });

  const handle = (e) => {
    setValues({ ...values, [e.target.name]: Number(e.target.value) });
  };

  return (
    <form
      onSubmit={(e) => { e.preventDefault(); onCalculate(values); }}
      style={{ display: 'grid', gap: '12px', maxWidth: '400px' }}
    >
      <label>
        Loan Amount ($)
        <input
          name="principal"
          type="number"
          value={values.principal}
          onChange={handle}
          min="1000"
          step="1000"
        />
      </label>

      <label>
        Annual Interest Rate (%)
        <input
          name="annualRate"
          type="number"
          value={values.annualRate}
          onChange={handle}
          min="0.1"
          max="30"
          step="0.05"
        />
      </label>

      <label>
        Term (years)
        <select name="termYears" value={values.termYears} onChange={handle}>
          <option value={10}>10 years</option>
          <option value={15}>15 years</option>
          <option value={20}>20 years</option>
          <option value={25}>25 years</option>
          <option value={30}>30 years</option>
        </select>
      </label>

      <button type="submit" disabled={loading}>
        {loading ? 'Calculating...' : 'Calculate'}
      </button>
    </form>
  );
}
Enter fullscreen mode Exit fullscreen mode

The Results Component

Create src/components/MortgageResults.jsx:

const fmt = (n) => new Intl.NumberFormat('en-US', {
  style: 'currency', currency: 'USD'
}).format(n);

export function MortgageResults({ data }) {
  const [showAll, setShowAll] = useState(false);
  const schedule = showAll ? data.schedule : data.schedule.slice(0, 12);

  return (
    <div>
      {/* Summary cards */}
      <div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '16px', margin: '24px 0' }}>
        <SummaryCard label="Monthly Payment" value={fmt(data.monthly_payment)} highlight />
        <SummaryCard label="Total Paid" value={fmt(data.total_paid)} />
        <SummaryCard label="Total Interest" value={fmt(data.total_interest)} />
      </div>

      {/* Interest vs principal pie-chart (CSS only) */}
      <InterestBreakdown
        principal={data.principal || (data.total_paid - data.total_interest)}
        interest={data.total_interest}
      />

      {/* Amortization table */}
      <h3>Payment Schedule</h3>
      <table style={{ width: '100%', borderCollapse: 'collapse' }}>
        <thead>
          <tr style={{ background: '#f5f5f5' }}>
            <th>Month</th><th>Payment</th><th>Principal</th><th>Interest</th><th>Balance</th>
          </tr>
        </thead>
        <tbody>
          {schedule.map(row => (
            <tr key={row.period} style={{ borderBottom: '1px solid #eee' }}>
              <td>{row.period}</td>
              <td>{fmt(row.payment)}</td>
              <td>{fmt(row.principal)}</td>
              <td>{fmt(row.interest)}</td>
              <td>{fmt(row.balance)}</td>
            </tr>
          ))}
        </tbody>
      </table>

      <button onClick={() => setShowAll(!showAll)}>
        {showAll ? 'Show less' : `Show all ${data.schedule.length} payments`}
      </button>
    </div>
  );
}

function SummaryCard({ label, value, highlight }) {
  return (
    <div style={{
      padding: '16px',
      border: `2px solid ${highlight ? '#2563eb' : '#e5e7eb'}`,
      borderRadius: '8px',
      textAlign: 'center',
    }}>
      <div style={{ fontSize: '12px', color: '#6b7280' }}>{label}</div>
      <div style={{ fontSize: '24px', fontWeight: 'bold', color: highlight ? '#2563eb' : '#111' }}>
        {value}
      </div>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Wire It Together

Update src/App.js:

import { useMortgage } from './hooks/useMortgage';
import { MortgageForm } from './components/MortgageForm';
import { MortgageResults } from './components/MortgageResults';

export default function App() {
  const { data, loading, error, calculate } = useMortgage();

  return (
    <div style={{ maxWidth: '900px', margin: '40px auto', padding: '0 20px' }}>
      <h1>Mortgage Calculator</h1>
      <MortgageForm onCalculate={calculate} loading={loading} />

      {error && (
        <div style={{ color: 'red', margin: '16px 0' }}>Error: {error}</div>
      )}

      {data && <MortgageResults data={data} />}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

The Result

After 10 minutes you have:

✅ Live mortgage calculations
✅ Full amortization schedule
✅ Zero financial math in your codebase
✅ Automatically handles rounding correctly

Extending This

The same API pattern works for:

Refinancing comparison — Call /mortgage twice with different rates and diff the results.

Rent vs. buy analysis — Combine /mortgage with /compound-interest (for investment returns on the down payment alternative).

Extra payment impact — Call /amortize with a reduced term to show how much interest extra payments save.

Deploy It

npm run build
# Deploy to Vercel, Netlify, or GitHub Pages — it's just static files
Enter fullscreen mode Exit fullscreen mode

Your API key is a client-side env variable which is fine for prototypes. For production, proxy the API calls through your own backend to keep the key server-side.


👉 Get your free API key — 50 calls/day on the free tier, no credit card required.

Questions? Drop them in the comments.

Top comments (0)