Build a Scientific Calculator 🧮
This is it. The boss battle of React beginner projects.
A scientific calculator isn't just a + b. It involves:
- Memory Management (M+, MR, MC)
- Order of Operations (PEMDAS)
- Complex State (Waiting for operands, handling chained calculations)
- Scientific Functions (Trigonometry in Degrees vs Radians)
In this deep-dive guide, we will build a production-grade calculator engine.
Step 1: The State Architecture 🏗️
You cannot just store a single "result". A calculator is a State Machine. At any given moment, the calculator is in one of these states:
- Idle: Displaying '0', waiting for input.
- Entering Number: User is typing digits (e.g., '123').
- Waiting for Operand: User hit '+', waiting for the second number.
- Error: User divided by zero.
The data hooks you need:
const [display, setDisplay] = useState("0"); // What the user sees
const [prevValue, setPrevValue] = useState(null); // The number BEFORE the operator
const [operator, setOperator] = useState(null); // The active operation (+, -, *)
const [waiting, setWaiting] = useState(false); // "Did user just hit +?"
Step 2: The Math Engine (It's built-in!) ⚙️
JavaScript's Math object is incredibly powerful.
We just need to map button clicks to these functions.
Trigonometry Trap:
JS Math.sin() expects Radians. Humans usually want Degrees.
You must convert!
const toRad = (deg) => deg * (Math.PI / 180);
const toDeg = (rad) => rad * (180 / Math.PI);
const calculate = (func, value) => {
switch(func) {
case "sin": return Math.sin(toRad(value)); // Assuming Degree Mode
case "log": return Math.log10(value); // Base 10 Log
case "ln": return Math.log(value); // Natural Log
case "sqrt": return Math.sqrt(value);
case "pow2": return Math.pow(value, 2);
default: return value;
}
}
Step 3: Handling Operations (+ - * /) 🔄
This is the hardest logic.
When a user hits +, we don't calculate yet. We store the current number and the operator, then wait for the next number.
Only when they hit = (or another operator) do we execute.
const handleOp = (op) => {
const current = parseFloat(display);
if (prevValue === null) {
// First time operator is pressed
setPrevValue(current);
} else if (operator) {
// Chained calculation (1 + 2 + ...)
// Calculate the pending result first!
const result = runMath(prevValue, current, operator);
setPrevValue(result);
setDisplay(String(result));
}
setWaiting(true); // Tell keypad: "Next digit starts a new number"
setOperator(op);
}
Step 4: The Full React Component 💻
Here is a streamlined version of the engine used in FastTools. It includes keyboard support and scientific functions.
"use client"
import { useState, useEffect } from "react"
import { Calculator } from "lucide-react"
export default function SciCalc() {
const [display, setDisplay] = useState("0")
const [prev, setPrev] = useState<number|null>(null)
const [op, setOp] = useState<string|null>(null)
const [waiting, setWaiting] = useState(false)
const [memory, setMemory] = useState(0)
// --- ACTIONS ---
const inputDigit = (digit: string) => {
if (waiting) {
setDisplay(digit)
setWaiting(false)
} else {
setDisplay(display === "0" ? digit : display + digit)
}
}
const performOp = (nextOp: string) => {
const inputValue = parseFloat(display)
if (prev === null) {
setPrev(inputValue)
} else if (op) {
const result = calculate(prev, inputValue, op)
setDisplay(String(result))
setPrev(result)
}
setWaiting(true)
setOp(nextOp)
}
const calculate = (a: number, b: number, operation: string) => {
switch(operation) {
case "+": return a + b
case "-": return a - b
case "×": return a * b
case "÷": return b !== 0 ? a / b : 0
default: return b
}
}
const handleFunc = (func: string) => {
const val = parseFloat(display)
if (func === "C") {
setDisplay("0"); setPrev(null); setOp(null); setWaiting(false);
return;
}
// Scientific Logic
let res = val
switch (func) {
case "sin": res = Math.sin(val * Math.PI / 180); break;
case "cos": res = Math.cos(val * Math.PI / 180); break;
case "tan": res = Math.tan(val * Math.PI / 180); break;
case "√": res = Math.sqrt(val); break;
case "sq": res = val * val; break;
case "log": res = Math.log10(val); break;
case "ln": res = Math.log(val); break;
}
setDisplay(String(res))
setWaiting(true) // Result is ready, next click starts new
}
// --- KEYBOARD SUPPORT ---
useEffect(() => {
const handleKey = (e: KeyboardEvent) => {
if (/\d/.test(e.key)) inputDigit(e.key)
if (e.key === "+") performOp("+")
if (e.key === "-") performOp("-")
if (e.key === "*") performOp("×")
if (e.key === "/") performOp("÷")
if (e.key === "Enter" || e.key === "=") performOp("=")
if (e.key === "Backspace") setDisplay(d => d.slice(0, -1) || "0")
}
window.addEventListener("keydown", handleKey)
return () => window.removeEventListener("keydown", handleKey)
}, [display, waiting, prev, op])
return (
<div className="max-w-sm mx-auto p-4 bg-slate-900 rounded-2xl shadow-2xl border border-slate-700">
{/* Display Screen */}
<div className="bg-slate-800 p-4 rounded-xl mb-4 text-right border border-slate-700 h-24 flex flex-col justify-end">
<div className="text-slate-400 text-xs h-4">{prev} {op}</div>
<div className="text-3xl font-mono text-white font-bold tracking-widest truncate">
{Number(display).toLocaleString("en-US", {maximumFractionDigits: 6})}
</div>
</div>
{/* Button Grid */}
<div className="grid grid-cols-4 gap-2">
{/* Row 1: Scientific */}
{['sin', 'cos', 'tan', 'C'].map(btn => (
<button key={btn} onClick={() => handleFunc(btn)}
className={`p-3 rounded-lg font-bold text-sm ${btn === 'C' ? 'bg-red-500 text-white' : 'bg-slate-700 text-slate-200 hover:bg-slate-600'}`}>
{btn}
</button>
))}
{/* Row 2: Scientific */}
{['√', 'sq', 'log', '÷'].map(btn => (
<button key={btn}
onClick={() => ['÷'].includes(btn) ? performOp(btn) : handleFunc(btn)}
className={`p-3 rounded-lg font-bold text-sm ${btn === '÷' ? 'bg-orange-500 text-white' : 'bg-slate-700 text-slate-200 hover:bg-slate-600'}`}>
{btn}
</button>
))}
{/* Numpad */}
{['7','8','9','×', '4','5','6','-', '1','2','3','+', '0','.','='].map(btn => (
<button key={btn}
onClick={() => {
if ('0123456789.'.includes(btn)) inputDigit(btn)
else if (btn === '=') performOp('=')
else performOp(btn)
}}
className={`p-4 rounded-lg font-bold text-lg transition
${['×','-','+','='].includes(btn) ? 'bg-orange-500 text-white hover:bg-orange-600' : 'bg-slate-100 text-slate-900 hover:bg-white'}
${btn === '0' ? 'col-span-2' : ''}
`}
>
{btn}
</button>
))}
</div>
</div>
)
}
Step 5: Keyboard Accessibility (Power User Feature) ⌨️
Did you notice the useEffect block?
Evaluating e.key allows users to type naturally on their keyboard.
Real calculators must support this. No one wants to click "1" then "2" with a mouse.
Key Mapping:
Enter->=*->×Backspace-> Delete last digitEscape-> Clear All (C)
Step 6: Decimal Precision (The Floating Point Bug) 🐛
Computers are bad at math. 0.1 + 0.2 often equals 0.30000000000000004 in JavaScript.
To fix this in a display:
- Do the math normally.
- When displaying, use a formatter.
num.toLocaleString()handles the commas and decimal limiting automatically, making "1000000" look like "1,000,000".