Table of Contents
1. Project Overview
This calculator project is more than a basic four-function app. It demonstrates core React patterns that you'll use in production: state management with useReducer, effect handling with useEffect, custom hooks, localStorage persistence, and keyboard event handling.
Features
- Basic operations: Addition, subtraction, multiplication, division
- Calculation history: Saved to localStorage, viewable in a side panel
- Keyboard support: Type numbers and operators directly from your keyboard
- Scientific functions: sin, cos, log, square root, and power
- Error handling: Division by zero, invalid expressions
- Responsive design: Works on desktop and mobile
2. Setting Up with Vite
Create a new React project with Vite for fast development.
# Create the project
npm create vite@latest calculator -- --template react
cd calculator
# Install dependencies
npm install
# No additional libraries needed — pure React!
Clean Up the Default Files
Remove the default Vite boilerplate from App.jsx and App.css. We'll build everything from scratch.
// src/main.jsx
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import './index.css';
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<App />
</React.StrictMode>
);
3. Building the Calculator UI
The calculator has two main parts: the display and the button grid.
// components/Display.jsx
export default function Display({ expression, result, history }) {
return (
<div className="calculator-display">
<div className="expression">
{expression || '0'}
</div>
<div className="result">
{result}
</div>
</div>
);
}
// components/ButtonGrid.jsx
const buttons = [
['C', '(', ')', '/'],
['7', '8', '9', '*'],
['4', '5', '6', '-'],
['1', '2', '3', '+'],
['0', '.', 'DEL', '='],
];
export default function ButtonGrid({ onButtonPress }) {
return (
<div className="button-grid">
{buttons.flat().map((btn) => (
<button
key={btn}
onClick={() => onButtonPress(btn)}
className={`calc-btn ${btn === '=' ? 'btn-equals' : ''}
${['+', '-', '*', '/', '(', ')'].includes(btn) ? 'btn-operator' : ''}
${btn === 'C' || btn === 'DEL' ? 'btn-action' : ''}`}
>
{btn}
</button>
))}
</div>
);
}
App Component
// App.jsx
import { useReducer } from 'react';
import Display from './components/Display';
import ButtonGrid from './components/ButtonGrid';
import History from './components/History';
import { calculatorReducer, initialState } from './reducer';
export default function App() {
const [state, dispatch] = useReducer(calculatorReducer, initialState);
return (
<div className="calculator-container">
<div className="calculator">
<Display expression={state.expression} result={state.result} />
<ButtonGrid onButtonPress={(btn) => dispatch({ type: 'BUTTON_PRESS', payload: btn })} />
</div>
<History history={state.history} />
</div>
);
}
4. Implementing Calculator Logic
We use useReducer to manage calculator state. This is cleaner than multiple useState calls and makes the state transitions explicit and testable.
// reducer.js
export const initialState = {
expression: '',
result: '0',
history: JSON.parse(localStorage.getItem('calcHistory')) || [],
shouldReset: false,
};
export function calculatorReducer(state, action) {
switch (action.type) {
case 'BUTTON_PRESS': {
const btn = action.payload;
if (btn === 'C') {
return { ...state, expression: '', result: '0', shouldReset: false };
}
if (btn === 'DEL') {
const newExpr = state.expression.slice(0, -1);
return { ...state, expression: newExpr, result: newExpr || '0' };
}
if (btn === '=') {
try {
const result = evaluateExpression(state.expression);
const newHistory = [
{ expression: state.expression, result: String(result) },
...state.history,
].slice(0, 20);
localStorage.setItem('calcHistory', JSON.stringify(newHistory));
return {
...state,
expression: '',
result: String(result),
history: newHistory,
shouldReset: true,
};
} catch {
return { ...state, result: 'Error' };
}
}
// Number or operator
const newExpression = state.shouldReset
? btn
: state.expression + btn;
return {
...state,
expression: newExpression,
result: newExpression,
shouldReset: false,
};
}
case 'LOAD_HISTORY': {
return {
...state,
expression: action.payload.expression,
result: action.payload.result,
shouldReset: false,
};
}
case 'CLEAR_HISTORY': {
localStorage.removeItem('calcHistory');
return { ...state, history: [] };
}
default:
return state;
}
}
function evaluateExpression(expr) {
// Sanitize: only allow numbers, operators, parens, decimals
if (!/^[\d+\-*/.()]+$/.test(expr)) {
throw new Error('Invalid expression');
}
// Use Function constructor for safe evaluation
const result = new Function(`return (${expr})`)();
if (!isFinite(result)) throw new Error('Invalid result');
return Math.round(result * 1e10) / 1e10; // Avoid floating point issues
}
eval() with user input in production. For a calculator, we sanitize the input with a regex and use new Function() in an isolated context. For production apps, consider a math expression parser library.
5. Handling Keyboard Input
Adding keyboard support makes the calculator much more usable. We'll listen for keydown events and map them to calculator actions.
// hooks/useKeyboard.js
import { useEffect } from 'react';
const KEY_MAP = {
'0': '0', '1': '1', '2': '2', '3': '3', '4': '4',
'5': '5', '6': '6', '7': '7', '8': '8', '9': '9',
'+': '+', '-': '-', '*': '*', '/': '/',
'.': '.', '(': '(', ')': ')',
Enter: '=', '=': '=',
Backspace: 'DEL',
Delete: 'C',
Escape: 'C',
};
export function useKeyboard(onKeyPress) {
useEffect(() => {
const handler = (e) => {
const action = KEY_MAP[e.key];
if (action) {
e.preventDefault();
onKeyPress(action);
}
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, [onKeyPress]);
}
Integrate into App
// In App.jsx
import { useKeyboard } from './hooks/useKeyboard';
import { useCallback } from 'react';
function App() {
const [state, dispatch] = useReducer(calculatorReducer, initialState);
const handleKeyPress = useCallback(
(btn) => dispatch({ type: 'BUTTON_PRESS', payload: btn }),
[]
);
useKeyboard(handleKeyPress);
// ... rest of component
}
6. Adding Calculation History
Persist calculation history to localStorage so users can review and reuse previous calculations.
// components/History.jsx
import { RotateCcw, Trash2 } from 'lucide-react';
export default function History({ history, onLoad, onClear }) {
if (history.length === 0) {
return (
<div className="history-panel">
<h3>History</h3>
<p className="empty-history">No calculations yet</p>
</div>
);
}
return (
<div className="history-panel">
<div className="history-header">
<h3>History</h3>
<button onClick={onClear} className="clear-btn">
<Trash2 size={16} />
</button>
</div>
<ul className="history-list">
{history.map((item, index) => (
<li key={index} onClick={() => onLoad(item)} className="history-item">
<span className="history-expression">{item.expression}</span>
<span className="history-result">= {item.result}</span>
</li>
))}
</ul>
</div>
);
}
localStorage Integration
The reducer already handles localStorage in the '=' case. We read from it on initialization and write to it on each evaluation. The history is capped at 20 entries to avoid storage bloat.
// Initialize from localStorage
const initialState = {
expression: '',
result: '0',
history: JSON.parse(localStorage.getItem('calcHistory')) || [],
shouldReset: false,
};
// Save on equals
localStorage.setItem('calcHistory', JSON.stringify(newHistory));
// Clear history
localStorage.removeItem('calcHistory');
7. Styling the Calculator
Use CSS Grid for the button layout and flexbox for the overall structure.
/* styles.css */
.calculator-container {
display: flex;
gap: 1rem;
max-width: 600px;
margin: 2rem auto;
padding: 1rem;
}
.calculator {
flex: 1;
background: #1a1a2e;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
}
.calculator-display {
padding: 1.5rem;
text-align: right;
min-height: 120px;
display: flex;
flex-direction: column;
justify-content: flex-end;
}
.expression {
font-size: 1rem;
color: #8888aa;
font-family: 'JetBrains Mono', monospace;
word-break: break-all;
}
.result {
font-size: 2.5rem;
font-weight: 700;
color: #ffffff;
font-family: 'JetBrains Mono', monospace;
margin-top: 0.5rem;
}
.button-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 1px;
padding: 1px;
}
.calc-btn {
aspect-ratio: 1;
border: none;
background: #16213e;
color: #ffffff;
font-size: 1.25rem;
font-family: 'Inter', sans-serif;
cursor: pointer;
transition: background 0.15s;
}
.calc-btn:hover {
background: #1a1a40;
}
.calc-btn:active {
background: #0f3460;
}
.btn-operator {
background: #0f3460;
color: #e94560;
}
.btn-equals {
background: #e94560;
color: #ffffff;
grid-column: span 1;
}
.btn-action {
color: #e94560;
font-weight: 600;
}
/* Responsive */
@media (max-width: 500px) {
.calculator-container {
flex-direction: column;
}
.history-panel {
max-height: 200px;
}
.result {
font-size: 2rem;
}
}
aspect-ratio: 1 on calculator buttons ensures they stay square regardless of screen size. This is much cleaner than fixed heights.
8. Adding Scientific Functions
Extend the calculator with trigonometric, logarithmic, and other scientific operations.
// reducer.js - Add scientific operations
const SCIENTIFIC_FUNCTIONS = {
sin: (x) => Math.sin(x * Math.PI / 180),
cos: (x) => Math.cos(x * Math.PI / 180),
log: (x) => Math.log10(x),
ln: (x) => Math.log(x),
sqrt: (x) => Math.sqrt(x),
pow2: (x) => Math.pow(x, 2),
};
case 'SCIENTIFIC': {
const { func, value } = action.payload;
const num = parseFloat(value || state.result);
if (isNaN(num)) return state;
const result = SCIENTIFIC_FUNCTIONS[func](num);
const newHistory = [
{ expression: `${func}(${num})`, result: String(result) },
...state.history,
].slice(0, 20);
localStorage.setItem('calcHistory', JSON.stringify(newHistory));
return {
...state,
expression: '',
result: String(Math.round(result * 1e10) / 1e10),
history: newHistory,
shouldReset: true,
};
}
Scientific Button Row
// components/ScientificButtons.jsx
const scientificBtns = ['sin', 'cos', 'log', 'sqrt', 'pow2'];
export default function ScientificButtons({ onScientific }) {
return (
<div className="scientific-row">
{scientificBtns.map((func) => (
<button
key={func}
onClick={() => onScientific(func)}
className="sci-btn"
>
{func === 'pow2' ? 'x²' : func}
</button>
))}
</div>
);
}
x * Math.PI / 180. You can add a degree/radian toggle as an extra feature.
9. Testing the Calculator
Unit test the reducer logic to ensure calculations are correct and edge cases are handled.
// reducer.test.js
import { describe, it, expect, beforeEach } from 'vitest';
import { calculatorReducer, initialState } from './reducer';
// Helper to run multiple button presses
function pressButtons(reducer, state, buttons) {
return buttons.reduce(
(s, btn) => reducer(s, { type: 'BUTTON_PRESS', payload: btn }),
state
);
}
describe('Calculator Reducer', () => {
let state;
beforeEach(() => {
state = { ...initialState, history: [] };
});
it('handles basic addition', () => {
state = pressButtons(calculatorReducer, state, ['2', '+', '3', '=']);
expect(state.result).toBe('5');
});
it('handles decimal numbers', () => {
state = pressButtons(calculatorReducer, state, ['1', '.', '5', '+', '2', '.', '5', '=']);
expect(state.result).toBe('4');
});
it('clears with C', () => {
state = pressButtons(calculatorReducer, state, ['5', '+', '3']);
state = calculatorReducer(state, { type: 'BUTTON_PRESS', payload: 'C' });
expect(state.expression).toBe('');
expect(state.result).toBe('0');
});
it('deletes last character with DEL', () => {
state = pressButtons(calculatorReducer, state, ['1', '2', '3']);
state = calculatorReducer(state, { type: 'BUTTON_PRESS', payload: 'DEL' });
expect(state.expression).toBe('12');
});
it('handles division by zero', () => {
state = pressButtons(calculatorReducer, state, ['5', '/', '0', '=']);
expect(state.result).toBe('Error');
});
it('saves history on equals', () => {
state = pressButtons(calculatorReducer, state, ['2', '*', '3', '=']);
expect(state.history).toHaveLength(1);
expect(state.history[0]).toEqual({ expression: '2*3', result: '6' });
});
it('handles chained operations', () => {
state = pressButtons(calculatorReducer, state, ['2', '+', '3', '=']);
state = calculatorReducer(state, { type: 'BUTTON_PRESS', payload: '*' });
state = pressButtons(calculatorReducer, state, ['4', '=']);
expect(state.result).toBe('20');
});
});
10. Deploying Your Calculator
Deploy to Vercel in under a minute.
Step 1: Push to GitHub
git init
git add .
git commit -m "feat: React calculator with keyboard support"
git remote add origin https://github.com/yourusername/calculator.git
git push -u origin main
Step 2: Deploy on Vercel
- Go to vercel.com and sign in with GitHub
- Click "Add New Project"
- Select your calculator repository
- Vercel auto-detects Vite — no configuration needed
- Click "Deploy"
Step 3: Custom Domain (Optional)
# In Vercel dashboard, go to Settings > Domains
# Add your custom domain and follow the DNS instructions
npm run build) and output directory (dist). Zero configuration required.
Performance Checklist
- Run
npm run buildand check the bundle size - Verify the app works at your deployment URL
- Test keyboard input on the live version
- Check responsive layout on mobile devices
- Verify localStorage persists across page reloads
Congratulations! You've built a fully functional calculator with React that includes keyboard support, history persistence, scientific functions, and tests. This project demonstrates clean state management with useReducer, custom hooks, and production deployment — skills that translate directly to larger applications.