DEV Community

CorvusEtiam
CorvusEtiam

Posted on

MVC Calculator in "almost" Vanilla JS

Edit determined-fire-8lh4i

For whom is it?

  • Javascript ★★★☆☆
  • Typescript ☆☆☆☆☆
  • HTML ★☆☆☆☆
  • CSS ★☆☆☆☆

A little bit of Typescript Love

First things first, I should explain myself what I mean by "almost" Vanilla JS.
I mean Typescript, one of rare good things in webdev.

But, but... that's not Vanilla JS!?

It is close enough. Don't worry, I am not going to use some type level magic to implement TicTacToe in pure types, I am not insane enough nor that smart. If you are looking for people who love to write angry Lisp, which looks like this:
Pick<ComponentProps<T>, Exclude<keyof ComponentProps<T>, 'key'

Loads of angry pointy brackets. Nope, no such stuff. Only semi-complex thing we will use are enums and type annotations. Both are pretty readable, and quite easy to understand.

If you never saw enum in your life, that is how you would make in Vanilla JS

const Color = Object.freeze({
   RED : 0,
   GREEN : 1,
   BLUE : 2
});
Enter fullscreen mode Exit fullscreen mode

With some Object.freeze added on top. They make it much easier for us to understand, what exact values you want to pass. Strings are passe. Seriously, strings are slow and hard to search. Use enums Luke!.

Type annotation looks like this:

function sum(a: number, b: number) : number { return a + b; }
Enter fullscreen mode Exit fullscreen mode

What's the point? The point is, if you for example pass string into this function, typescript compiler will be angry at you and if you want to change anything in you code it will scream at you at every error.

I don't know about you, but I prefer if compiler scream at me if I mess up, because otherwise this mess can very well end-up in my or yours browser.

Everyone makes mistakes... and autocompletition in VS Code is so good.

Typescript Love -- OFF

Now, we will need to setup everything... Gosh.
Ok, I am just kidding. Just click on this Big button on top of this blog post. It will open codesandbox.io editor for you, which is pretty cool.
It supports most of the stuff from Vs Code (they share editor widget), works plenty fast and it will make whole setup a breeze.
If for some reason, you don't wont to use this service and prefer to have everything on your own machine.

(Psst, what are you? Some cave-dweller? In age when we overshare every single tidbit about our live in Social Media, an place all our data in the cloud.
). Don't worry. I am with you!

You can archive exactly the same thing with those commands.

npm init
npm install parcel-builder typescript
tsc --init
parcel ./.html

After downloading half of the internet, which tend to happen each time you use you NPM, open localhost: in your browser. For me it is localhost:1234

Now open your html file. It is most likely empty.
Because it is not HTML+CSS tutorial just copy this.

<html>
  <head>
    <title>Parcel Sandbox</title>
    <meta charset="UTF-8" />
  </head>

  <body>
    <style>
      .grid {
        display: grid;
        width: 300px;
        height: 300px;
        grid-template-rows: 90px 90px 90px 90px;
        grid-template-columns: 90px 90px 90px;
      }
    </style>
    <div id="app">
      <label for="calcValue">Value: </label>
      <input type="text" id="calcValue" />
      <button class="op" data-op="eq">=</button>
      <p>
        Current operator:
        <span id="currOp"></span>
      </p>
      <div class="grid">
        <button class="num">0</button>
        <button class="num">1</button>
        <button class="num">2</button>
        <button class="num">3</button>
        <button class="num">4</button>
        <button class="num">5</button>
        <button class="num">6</button>
        <button class="num">7</button>
        <button class="num">8</button>
        <button class="op" data-op="plus">+</button>
        <button class="num">9</button>
        <button class="op" data-op="minus">-</button>
      </div>
    </div>

    <script src="src/index.ts"></script>
  </body>
</html>
Enter fullscreen mode Exit fullscreen mode

But I don't know about...!

There are few things, which I have to explain. Ok, happy?

  • Everything CSS and display:grid -- just go to CSSTricks Complete Guide to CSS Grid
  • data-op attribute -- those are user-defined attribute. There is pretty cool guide about them on MDN. You can access them in your JS with html_element.dataset.op, they can hold some state and data for your app.
  • src/index.ts that is Parcel for you. It will automatically detect and generate resources for you. It is super cool and plenty fast. Much easier to use then webpack.

And I use classes for non-stylistic purpose. If you want to make it fully-Kosher, just change those num and op classes into respective data-attributes.

Time to start, for real!

Now time for some JS. But first, let me talk about design first.

App Flowchart

It is not pretty, but it explains what I am planning to do.
First there is Calculator, which is our entry point.

It loads out application. It is responsible for creation of our controller.
This is master class, which should contain all the logic.

CalculatorView is responsible for setting up all event handlers, styling and possible all operation with DOM.

CalculatorState should be as plain and simple as possible. It makes sense to put there stuff responsible for fetching data, storing stuff into local storage.
I mean, general state management. It shouldn't have too much logic. Simple is better!

Why not just put it into one class? That kind of design makes it easier to untie you code later on and extend it. It is also easier to read. Minus is, it is longer and less procedural...

CalculatorApp

Let me start from easiest part.


enum Op {
    Eq = 'eq',
    Minus = 'minus',
    Plus = 'plus'
};

function calculator_app() {
    //// setup view
    //// setup state
    ///  setup controller
}

calculator_app();

Enter fullscreen mode Exit fullscreen mode

That's all. Rest will take place inside classes.
Additionally I added enum, which will represent all buttons, which are not digits. Currently we support only 3 operations.

Now enter the View

CalculatorView

I will use classes. They are mostly the same to ES6 ones. You may change it into old school function and bunch of prototypes. It will be exactly the same. We wont use any fancy features here.

class CalculatorView {
Enter fullscreen mode Exit fullscreen mode

Now, one of the TS-things. We need to declare types for our member variables.
Those should be pretty self-explanatory.
Before : you have name, after the type of your variable. Most of them, as you may already guessed are types of different DOM elements. It is pretty useful, because later on we will see cool autocompletition for those.

The only unknown over here is the CalcController. This is not yet defined, but if you remember out little diagram, that is a place, where everything will happen.

  root: Element;
  controller?: CalcController;
  input: HTMLInputElement;
  current_op: HTMLParagraphElement;
Enter fullscreen mode Exit fullscreen mode
  constructor(root: Element) {
    this.root = root;
    this.input = this.root.querySelector("input#calcValue") as HTMLInputElement;
    this.current_op = this.root.querySelector(
      "#currOp"
    ) as HTMLParagraphElement;
  }

  init() {
    this.root.addEventListener("click", ev => this.click(ev));
  }
Enter fullscreen mode Exit fullscreen mode

Some initialization. Nothing super important. Only thing, that may look weird to you is that, I setup only one event handler for my whole CalcApp.
You could do the same setting up handler for each button. Frankly, I found it harder to read.
Here I rely on something not always fully understood and probably worth post by itself -- Event Bubbling up frow low level DOM Elements to they parents and so on.

  click(ev: Event) {
    const target = ev.target as HTMLElement;
    if (target.classList.contains("num")) {
      this.controller.handle_digit(target.innerText);
    } else if (target.classList.contains("op")) {
      const op : Op = target.dataset.op;
      switch (op) {
        case Op.Minus:
        case Op.Plus:
          this.controller.handle_bin_op(op);
          break;
        case Op.Eq:
          this.controller.handle_eq();
          break;
      }
    }
  }

  set_current_op(op?: Op) {
    if ( op !== undefined ) {
       this.view.current_op.innerText = op.toString();
    }
  }

  set_input(inp: string) {
    this.view.input.value = state.input;
  }
}
Enter fullscreen mode Exit fullscreen mode

And our event handler. Nothing to complex. I used as operator to change (cast) types from default EventTarget to HTMLElement.
All the real work happens within Controller.

CalculatorState

Now, time for another simple component. Just with a little twist this time.

type State = {
  op?: Op;
  values: number[];
  input: string;
};
Enter fullscreen mode Exit fullscreen mode

First we will define new typed object litteral. The little ? sign tells you, that value may be undefined.
Why do we need it? It will be more obvious in the moment. Trust me.

class CalcState {
  controller?: CalcController;
  state: State;

  constructor() {
    this.state = {
      values: [],
      input: ""
    };
  }

  update_state(callback: (old: State) => State) {
    const state = callback({
      op: this.state.op,
      input: this.state.input,
      values: [...this.state.values]
    });

    this.state.values = state.values;
    this.state.op = state.op;
    this.state.input = state.input;

    this.controller.render(this.state);
  }
}
Enter fullscreen mode Exit fullscreen mode

And here we place rest of state class. Why I designed it, this way?
There are a lot of approaches to implement MVC pattern. Of course we could keep with Java-style getters and setters. Frankly, that would be even easier. I took a bit different route.
Why? Because this is a bit easier to debug. You have less points of failures and can put all your checks in one place. It keeps you view logic as simple as possible. In more complex app, View will be responsible for templating, and pushing all your data from state to the user.

State is your data. It is most important part of the whole app, even if it won't do much.
Every time, you want to change state, you should make those updates from within callback.
This is also a reason, to make additional State type.

Frankly, whole CalcState class could be fully generic and work for any kind of state.
There are one important detail.

Spaghetti is great... for dinner!

When you want to change anything in our View, you shouldn't call it directly.
State shouldn't even know about your View. All the communication should happen with the use of controller, otherwise you may make God of Pasta, very angry.

CalcController

Now, our biggest and most complex classes.
CalcController, the mastermind of all operation.

class CalcController {
  view: CalcView;
  state: CalcState;
  handlers: OperationMap;
  constructor(state: CalcState, view: CalcView) {
    this.state = state;
    this.view = view;
    this.state.controller = this;
    this.view.controller = this;

    this.view.init();
  }
Enter fullscreen mode Exit fullscreen mode

First some initialization and passing our controller instance into State and View.
We also initialize our View.

  handle_bin_op(op: Op) {
    this.state.update_state(state => {
      state.op = op;
      if (state.input === "") {
        return state;
      }
      state.values.push(Number(state.input));
      state.input = "";
      console.log(state);
      return state;
    });
  }

  handle_digit(digit: string) {
    this.state.update_state(state => {
      if (state.input === "" || state.input === "0") {
        state.input = digit;
      } else {
        state.input = state.input + digit;
      }
      return state;
    });
  }

  handle_eq() {
    this.state.update_state(state => {
      if (state.values.length === 0) {
        return state;
      }

      if (state.input !== "") {
        state.values.push(Number(state.input));
      }

      const a = state.values.pop();
      const b = state.values.pop();
      console.log("%s %d %d", state.op, b, a);
      if (state.op === Op.Plus) {
        state.input = (a + b).toString();
      } else if (state.op === Op.Minus) {
        state.input = (b - a).toString();
      }
      return state;
    });
  }

Enter fullscreen mode Exit fullscreen mode

And rest of the logic. See, how we are not changing any state data or view data directly.
Everything is neatly connected.

  render(state: State) {
    this.view.set_current_op(state.op.toString())
    this.view.set_input(state.input);    
  }
}
Enter fullscreen mode Exit fullscreen mode

And this is only place, where we update whole view.

What's the point?

Ok, my React is doing all of that for me. It works fine. Why would I need something like this?

Look at how our state is implemented, our updates. React works similarly under-the-hood.
Try to reimplement it in React. See, how similar it will be. Of course it will be all within one class or even one function.

The thing is knowing all of that, you may a little bit better understand, that there is no single method and that, your React/Vue/anything else is not a magic and for small projects you don't need all of that.

There is pretty cool tool I found recently and even wrote short guide on, known as Svelte.
It is super cool, because it will generate most of the stuff I shown you here for you.
Write a little bit modified JS and you will get whole thing super small and for free.

I would love to hear, what do you think about my article.

Cheers!

Top comments (3)

Collapse
 
aminnairi profile image
Amin

Hi there.

You said in your first example that this was how to make an enumeration in JavaScript:

const Color = {
   RED = 0,
   GREEN = 1,
   BLUE = 2
};

I believe the right example was:

const COLOR = {
  RED: 1,
  GREEN: 2,
  BLUE: 3
};

The equal sign is not a valid operator for assigning value to a property inside of an object in JavaScript.

Collapse
 
corvusetiam profile image
CorvusEtiam

Yes, you are right. That is what you get, after using TS too much :).

I would even go as far to tell the best way would be something like this:

const COLOR = Object.freeze({
  RED: 1,
  GREEN: 2,
  BLUE: 3
});

Thanks.

Collapse
 
aminnairi profile image
Amin

Haha, I knew that! Yes I though about adding the Object.freeze, but I didn't know whether you wanted to go this far or not. But it's great that you added a fully capable example.

Great article. Keep up the good work!