Back to Blog

React Calculator Project Tutorial

Build a feature-rich calculator with keyboard support, history, and scientific functions.

React Calculator Project Tutorial

From basic operations to scientific functions — a complete calculator built with React and useReducer.

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
Why a calculator? Don't underestimate this project. A well-built calculator demonstrates your understanding of state machines, reducer patterns, event handling, and clean component architecture — exactly what interviewers want to see.

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>
);
No extra dependencies: This project uses only React. No state management libraries, no utility packages. This keeps the bundle small and demonstrates that you can build functionality from scratch.

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
}
Security note: Never use 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
}
Why useCallback? We wrap the dispatch callback to prevent the keyboard hook from re-attaching the event listener on every render. This is a common performance optimization.

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 trick: Using 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>
  );
}
Angle mode: By default, trigonometric functions use degrees (not radians). Convert with 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');
  });
});
Test edge cases: Focus on division by zero, empty expressions, consecutive operators, and floating point precision. These are the bugs that ship to production.

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

  1. Go to vercel.com and sign in with GitHub
  2. Click "Add New Project"
  3. Select your calculator repository
  4. Vercel auto-detects Vite — no configuration needed
  5. Click "Deploy"

Step 3: Custom Domain (Optional)

# In Vercel dashboard, go to Settings > Domains
# Add your custom domain and follow the DNS instructions
Vercel + Vite: Vercel automatically detects Vite projects and sets the correct build command (npm run build) and output directory (dist). Zero configuration required.

Performance Checklist

  • Run npm run build and 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.

Next steps: Add unit conversion, graphing capabilities, or integrate with a math API for symbolic computation. Each addition pushes your skills further.
Back to Top