How to Build a React Calculator from Scratch

📅 3 years ago 🕒 19 min read 🙎‍♂️ by Madza

How to Build a React Calculator from Scratch

In this tutorial, we will be building a React Calculator app. You will learn how to make a wireframe, design a layout, create components, update states, and format the output.

To get you inspired, here is the link to the deployed project of what we will be building.

Also, here is the source code, just for the reference if you need help in any stage of the project.

Planning

Since we will be building a Calculator app, let's pick a scope, that is not too complicated for learning but also not too basic for covering different aspects of creating an app.

The features we will implement include:

  1. Add, subtract, multiply, divide
  2. Support decimal values
  3. Calculate percentages
  4. Invert the values
  5. Reset functionality
  6. Format larger numbers
  7. Output resize based on length

To start off, we will draw a basic wireframe to display our ideas. For this, you can use free tools like Figma or Diagrams.net.

Wireframe

Note that in this phase, it is not that important to think about colors and styling. What matter the most is that you can structure the layout and it's best if you can identify the components.

Design

Since we already know the layout and the components, all that's left to do to complete the design is to pick a nice color scheme.

Below are the rules that would make the app look great:

  1. The wrapper has a great contrast to the background
  2. The screen and button values are easy to read
  3. The equal button is in a different color, to give some accent

Based on the criteria above, we are gonna use the following color schema:

Color scheme

Set up the project

To start, open the terminal in your Projects folder and create a boilerplate template using the create-react-app. To do that, run the command:

npx create-react-app calculator

It's the fastest and easiest way to set up a fully working React app with zero-config. All you need to do after that is run cd calculator to switch to the newly created project folder and npm start to start your app in the browser.

Browser view

As you can see it comes with some default boilerplate, so next we are gonna do some clean up in the project folder tree.

Find the src folder, where the logic of your app will live and remove everything except App.js to create your app, index.css to style your app, and index.js to render your app in the DOM.

Project tree

Create components

Since we already did some wireframing before, we already know the main building blocks of the application. Those are Wrapper, Screen, ButtonBox, and Button.

First create a components folder inside the src folder. We will then create a separate .js file and .css file for each component.

Wrapper

The Wrapper component will be the frame, holding all the children components in place. It will also allow us to center the whole app afterward.

Wrapper.js

import React from "react";
import "./Wrapper.css";

const Wrapper = ({ children }) => {
  return <div className="wrapper">{children}</div>;
};

export default Wrapper;

Wrapper.css

.wrapper {
  width: 340px;
  height: 540px;
  padding: 10px;
  border-radius: 10px;
  background-color: #485461;
  background-image: linear-gradient(315deg, #485461 0%, #28313b 74%);
}

Screen

The Screen component will be the top section children in the Wrapper component and its purpose will be to display the calculated values.

In the features list, we included display output resize on length, meaning longer values must shrink in size. We will use a small (3.4kb gzip) library called react-textfit for that.

To install it, run npm i react-textfit and then import and use it like shown below.

Screen.js

import React from "react";
import { Textfit } from "react-textfit";
import "./Screen.css";

const Screen = ({ value }) => {
  return (
    <Textfit className="screen" mode="single" max={70}>
      {value}
    </Textfit>
  );
};

export default Screen;

Screen.css

.screen {
  height: 100px;
  width: 100%;
  margin-bottom: 10px;
  padding: 0 10px;
  background-color: #4357692d;
  border-radius: 10px;
  display: flex;
  align-items: center;
  justify-content: flex-end;
  color: white;
  font-weight: bold;
  box-sizing: border-box;
}

ButtonBox

The ButtonBox component, similarly to the Wrapper component, will be the frame for the children - only this time for the Button components.

ButtonBox.js

import React from "react";
import "./ButtonBox.css";

const ButtonBox = ({ children }) => {
  return <div className="buttonBox">{children}</div>;
};

export default ButtonBox;

ButtonBox.css

.buttonBox {
  width: 100%;
  height: calc(100% - 110px);
  display: grid;
  grid-template-columns: repeat(4, 1fr);
  grid-template-rows: repeat(5, 1fr);
  grid-gap: 10px;
}

Button

The Button component will provide the interactivity for the app. Each component will have the value and onClick props.

In the stylesheet, we will also include the styles for the equal button. We will use Button props to access the class later on.

Button.js

import React from "react";
import "./Button.css";

const Button = ({ className, value, onClick }) => {
  return (
    <button className={className} onClick={onClick}>
      {value}
    </button>
  );
};

export default Button;

Button.css

button {
  border: none;
  background-color: rgb(80, 60, 209);
  font-size: 24px;
  color: rgb(255, 255, 255);
  font-weight: bold;
  cursor: pointer;
  border-radius: 10px;
  outline: none;
}

button:hover {
  background-color: rgb(61, 43, 184);
}

.equals {
  grid-column: 3 / 5;
  background-color: rgb(243, 61, 29);
}

.equals:hover {
  background-color: rgb(228, 39, 15);
}

Render elements

The base file for rendering in React apps is index.js. Before we go further, make sure your index.js looks as follows:

import React from "react";
import ReactDOM from "react-dom";

import App from "./App";
import "./index.css";

ReactDOM.render(
  <React.StrictMode>
    <App />
  </React.StrictMode>,
  document.getElementById("root")
);

Also, let's check index.css and make sure we reset the default values for padding and margin, pick some great font (like Montserrat in this case) and set the proper rules to center the app in the viewport.

@import url("https://fonts.googleapis.com/css2?family=Montserrat&display=swap");

* {
  margin: 0;
  padding: 0;
  font-family: "Montserrat", sans-serif;
}

body {
  height: 100vh;
  display: flex;
  align-items: center;
  justify-content: center;
  background-color: #fbb034;
  background-image: linear-gradient(315deg, #fbb034 0%, #ffdd00 74%);
}

Finally, let's open the main file App.js, and import all the components we created previously.

import React from "react";

import Wrapper from "./components/Wrapper";
import Screen from "./components/Screen";
import ButtonBox from "./components/ButtonBox";
import Button from "./components/Button";

const App = () => {
  return (
    <Wrapper>
      <Screen value="0" />
      <ButtonBox>
        <Button
          className=""
          value="0"
          onClick={() => {
            console.log("Button clicked!");
          }}
        />
      </ButtonBox>
    </Wrapper>
  );
};

export default App;

In the example above we have rendered just a single Button component.

Let's create an array representation of the data in the wireframe, so we can map through and render all the buttons in the ButtonBox.

const btnValues = [
  ["C", "+-", "%", "/"],
  [7, 8, 9, "X"],
  [4, 5, 6, "-"],
  [1, 2, 3, "+"],
  [0, ".", "="],
];

Next, include this code between the ButtonBox tags.

{
  btnValues.flat().map((btn, i) => {
    return (
      <Button
        key={i}
        className={btn === "=" ? "equals" : ""}
        value={btn}
        onClick={() => {
          console.log(`${btn} clicked!`);
        }}
      />
    );
  });
}

Now open your browser. If you followed along, your current result should look like this:

App design

If you want, you can also open Dev tools (from the browser Settings or by pressing F12) and test out the log values for each button pressed.

Console.log

Define states

Next, we will declare the state variables using React useState hook.

Specifically, there will be three states: num - the entered value, sign - the selected sign, and res - the calculated value.

In order to use the useState hook, we must first import it in App.js.

import { useState } from "react";

In the App function, we will use an object to set all states at once.

let [calc, setCalc] = useState({
  sign: "",
  num: 0,
  res: 0,
});

Functionality

Our app looks nice, but there is no functionality. Currently it can only output button values into the browser console. Let's fix that!

We will start with the Screen component. Set the following conditional logic to value prop, so it knows when and what input to display:

<Screen value={calc.num ? calc.num : calc.res} />

Now lets edit the Button component, so it can detect different button types and execute the assigned function once the specific button is pressed. Use the code below:

<Button
	key={i}
	className={btn === "=" ? "equals" : ""}
	value={btn}
	onClick={
		btn === "C"
			? resetClickHandler
			: btn === "+-"
			? invertClickHandler
			: btn === "%"
			? percentClickHandler
			: btn === "="
			? equalsClickHandler
			: btn === "/" || btn === "X" || btn === "-" || btn === "+"
			? signClickHandler
			: btn === "."
			? comaClickHandler
			: numClickHandler
	}
/>

Now we are ready to create all the necessary functions.

numClickHandler

The numClickHandler function gets triggered only if any of the number buttons (0-9) is pressed. Then it gets the value of the Button and adds that to the current num value.

It will also make sure that:

  1. No whole numbers start with zero
  2. No multiple zeros before the comma
  3. Format to "0." if "." is pressed first
  4. Enter numbers up to 16 integers long
const numClickHandler = (e) => {
  e.preventDefault();
  const value = e.target.innerHTML;

  if (calc.num.length < 16) {
    setCalc({
      ...calc,
      num:
        calc.num === 0 && value === "0"
          ? "0"
          : calc.num % 1 === 0
          ? Number(calc.num + value)
          : calc.num + value,
      res: !calc.sign ? 0 : calc.res,
    });
  }
};

comaClickHandler

The comaClickHandler function gets fired only if the coma value ('.') is pressed. It adds the coma to the current num value, making it a decimal number.

It will also make sure that no multiple commas are possible.

const comaClickHandler = (e) => {
  e.preventDefault();
  const value = e.target.innerHTML;

  setCalc({
    ...calc,
    num: !calc.num.toString().includes(".") ? calc.num + value : calc.num,
  });
};

signClickHandler

The signClickHandler function gets fired when the user press either '+', '-', '*' or '/'. The particular value is then set as a current sign value in the calc object.

It will also make sure that there is no effect on repeated calls.

const signClickHandler = (e) => {
  e.preventDefault();
  const value = e.target.innerHTML;

  setCalc({
    ...calc,
    sign: value,
    res: !calc.res && calc.num ? calc.num : calc.res,
    num: 0,
  });
};

equalsClickHandler

The equalsClickHandler function calculates the result when the equals button ('=') is pressed. The calculation is based on the current num and res value, as well as the sign selected (see the math function).

The returned value is then set as the new res for the further calculations.

It will also make sure that:

  1. There is no effect on repeated calls
  2. Users can't divide with 0
const equalsClickHandler = () => {
  if (calc.sign && calc.num) {
    const math = (a, b, sign) =>
      sign === "+"
        ? a + b
        : sign === "-"
        ? a - b
        : sign === "X"
        ? a * b
        : a / b;

    setCalc({
      ...calc,
      res:
        calc.num === "0" && calc.sign === "/"
          ? "Can't divide with 0"
          : math(Number(calc.res), Number(calc.num), calc.sign),
      sign: "",
      num: 0,
    });
  }
};

invertClickHandler

The invertClickHandler function first checks if there is any entered value (num) or calculated value (res) and then inverts them by multiplying with -1.

const invertClickHandler = () => {
  setCalc({
    ...calc,
    num: calc.num ? calc.num * -1 : 0,
    res: calc.res ? calc.res * -1 : 0,
    sign: "",
  });
};

percentClickHandler

The percentClickHandler function checks if there is any entered value (num) or calculated value (res) and then calculates the percentage using JS built in Math.pow function, which returns the base to the exponent power.

const percentClickHandler = () => {
  let num = calc.num ? parseFloat(calc.num) : 0;
  let res = calc.res ? parseFloat(calc.res) : 0;

  setCalc({
    ...calc,
    num: (num /= Math.pow(100, 1)),
    res: (res /= Math.pow(100, 1)),
    sign: "",
  });
};

resetClickHandler

The resetClickHandler function defaults all the initial values of calc, returning the calc state as it was when the Calculator app was first rendered.

const resetClickHandler = () => {
  setCalc({
    ...calc,
    sign: "",
    num: 0,
    res: 0,
  });
};

Input Formatting

One last thing to complete the feature list in the intro would be to implement value formatting. For that, we could use a modified Regex string posted by Emissary.

const toLocaleString = (num) =>
  String(num).replace(/(?<!\..*)(\d)(?=(?:\d{3})+(?:\.|$))/g, "$1 ");

Essentially what it does is take a number, format it into the string format and create the space separators for the thousand mark.

If we reverse the process and want to process the string of numbers, first we need to remove the spaces, so we can later convert it to number. For that you can use the function:

const removeSpaces = (num) => num.toString().replace(/\s/g, "");

Check out the next section with full code on how to add toLocaleString and removeSpaces to the handler functions for the Button component.

Putting it all together

If you followed along, the whole App.js code would look like this:

import React, { useState } from "react";

import Wrapper from "./components/Wrapper";
import Screen from "./components/Screen";
import ButtonBox from "./components/ButtonBox";
import Button from "./components/Button";

const btnValues = [
  ["C", "+-", "%", "/"],
  [7, 8, 9, "X"],
  [4, 5, 6, "-"],
  [1, 2, 3, "+"],
  [0, ".", "="],
];

const toLocaleString = (num) =>
  String(num).replace(/(?<!\..*)(\d)(?=(?:\d{3})+(?:\.|$))/g, "$1 ");

const removeSpaces = (num) => num.toString().replace(/\s/g, "");

const App = () => {
  let [calc, setCalc] = useState({
    sign: "",
    num: 0,
    res: 0,
  });

  const numClickHandler = (e) => {
    e.preventDefault();
    const value = e.target.innerHTML;

    if (removeSpaces(calc.num).length < 16) {
      setCalc({
        ...calc,
        num:
          calc.num === 0 && value === "0"
            ? "0"
            : removeSpaces(calc.num) % 1 === 0
            ? toLocaleString(Number(removeSpaces(calc.num + value)))
            : toLocaleString(calc.num + value),
        res: !calc.sign ? 0 : calc.res,
      });
    }
  };

  const comaClickHandler = (e) => {
    e.preventDefault();
    const value = e.target.innerHTML;

    setCalc({
      ...calc,
      num: !calc.num.toString().includes(".") ? calc.num + value : calc.num,
    });
  };

  const signClickHandler = (e) => {
    e.preventDefault();
    const value = e.target.innerHTML;

    setCalc({
      ...calc,
      sign: value,
      res: !calc.res && calc.num ? calc.num : calc.res,
      num: 0,
    });
  };

  const equalsClickHandler = () => {
    if (calc.sign && calc.num) {
      const math = (a, b, sign) =>
        sign === "+"
          ? a + b
          : sign === "-"
          ? a - b
          : sign === "X"
          ? a * b
          : a / b;

      setCalc({
        ...calc,
        res:
          calc.num === "0" && calc.sign === "/"
            ? "Can't divide with 0"
            : toLocaleString(
                math(
                  Number(removeSpaces(calc.res)),
                  Number(removeSpaces(calc.num)),
                  calc.sign
                )
              ),
        sign: "",
        num: 0,
      });
    }
  };

  const invertClickHandler = () => {
    setCalc({
      ...calc,
      num: calc.num ? toLocaleString(removeSpaces(calc.num) * -1) : 0,
      res: calc.res ? toLocaleString(removeSpaces(calc.res) * -1) : 0,
      sign: "",
    });
  };

  const percentClickHandler = () => {
    let num = calc.num ? parseFloat(removeSpaces(calc.num)) : 0;
    let res = calc.res ? parseFloat(removeSpaces(calc.res)) : 0;

    setCalc({
      ...calc,
      num: (num /= Math.pow(100, 1)),
      res: (res /= Math.pow(100, 1)),
      sign: "",
    });
  };

  const resetClickHandler = () => {
    setCalc({
      ...calc,
      sign: "",
      num: 0,
      res: 0,
    });
  };

  return (
    <Wrapper>
      <Screen value={calc.num ? calc.num : calc.res} />
      <ButtonBox>
        {btnValues.flat().map((btn, i) => {
          return (
            <Button
              key={i}
              className={btn === "=" ? "equals" : ""}
              value={btn}
              onClick={
                btn === "C"
                  ? resetClickHandler
                  : btn === "+-"
                  ? invertClickHandler
                  : btn === "%"
                  ? percentClickHandler
                  : btn === "="
                  ? equalsClickHandler
                  : btn === "/" || btn === "X" || btn === "-" || btn === "+"
                  ? signClickHandler
                  : btn === "."
                  ? comaClickHandler
                  : numClickHandler
              }
            />
          );
        })}
      </ButtonBox>
    </Wrapper>
  );
};

export default App;

Final notes

Congratulations! You have created a fully functional and styled app. Hopefully, you learned a thing or two during the process!

Demo

Some ideas for you would be to add some scientific features or to implement the memory with the list of previous calculations.

If you have any issue reports or feature requests, feel free to leave them in the GitHub repo. If you like the project, feel free to star it.


Writing has always been my passion and it gives me pleasure to help and inspire people. If you have any questions, feel free to reach out!