DEV Community

loading...

Extending the functionality of React Calculator xState example

Alina Mihai
Senior Frontend Developer
・9 min read

Who is this article for?

  • you have some understanding of state machines and state charts
  • you are comfortable with simple xState syntax and concepts

If you are just starting out with xState there are a bunch of great resources out there that can provide a good intro. For example this mini series on xState

1. Starting point

I chose the xState Calculator example to get a feel for how easy or difficult it is to work in a code base that uses xState and add new functionalities.

This was the codebase I forked: xState Calculator Example

For brevity I will be showing only the relevant changes for each step.
If you'd like to skip to the end and see the final code click here.

First thing I did was to add a new button for toggling the sign of the number and adjust the styling of the calculator.

I removed the grid gap and brought the buttons closer together to avoid the Hermann Grid illusion. I also changed the colour of the operators and equal sign to better differentiate them from the rest of the buttons.
Path: src/Calculator.tsx

const ButtonGrid = styled.div`
  display: grid;
  grid-template-columns: repeat(4, 1fr);
`;

const Button = styled.button`
  ...
  border-width: 1px !important;
  ...
  &.clear-btn {
    background-color: #3572db;
  }
  &.operator {
    background-color: #2b1b06;
    border-color: #2b1b06;
  }
`;
function addButtonClasses(text) {
   const classes = [''];
   if(isOperator(text) || text === '=') {
     classes.push('operator')
   } 
   else if(text === 'C') {
     classes.push('clear-btn');
   }
   return classes.join(' ');
}

const Calculator = () => {
  const [state, sendMachine] = useMachine(machine, {});
        {buttons.map((btn, index) => (
          <Button
            className={addButtonClasses(btn)}
            type="button"
            key={index}
            onClick={handleButtonClick(btn)}
Enter fullscreen mode Exit fullscreen mode

Adding the logic for the +/- button was relatively easy.
I added a new if statement on the handleButtonClick method to send a TOGGLE_SIGN event to the machine when the clicked button was '+/-'

Path: src/Calculator.tsx:

const handleButtonClick = item => () => {
    ...
    else if( item === '+/-') {
      send('TOGGLE_SIGN', {});
    } 
   ...
Enter fullscreen mode Exit fullscreen mode

Next, I added the necessary logic to the machine in /machine.ts. When the state was operand1 and the user toggled the sign by clicking the +/- button, it would go into the negative_number state, and if the user toggled the sign again by clicking the +/- button again, it would transition back to operand1 state. The same logic applies for operand2 and negative_number_2 states.
I also added a guard to prevent converting zero to a negative number.

Path src/machine.ts


const isDisplayZero = (context) => context.display === '0.';
const isNotDisplayZero = not(isDisplayZero);
...
const calMachine = Machine<Context>(
  {
   ...
  },
    states: {
     ...
     operand1: {
        on: {
          ...,
          TOGGLE_SIGN: {
              cond: 'isNotDisplayZero',
              target: 'negative_number',
              actions: ['toggleSign'],
          },
         ...
       },
      ... 
   },
   negative_number: {
        on: {
         ...,
          TOGGLE_SIGN: {
            target: 'operand1',
            actions: ['toggleSign'],
          },
          ...
      },
   },
   operand2: {
        on: {
          ...,
          TOGGLE_SIGN: {
              cond: 'isNotDisplayZero',
              target: 'negative_number_2',
              actions: ['toggleSign'],
          },
         ...
       },
      ... 
   },
   negative_number_2: {
        on: {
         ...,
          TOGGLE_SIGN: {
            target: 'operand2',
            actions: ['toggleSign'],
          },
          ...
      },
  }
  ...
  }, {
   guards: {
      ...
      isNotDisplayZero
   },
   actions: {
     ...,
     toggleSign: assign({
        display: (context) => {
          if (context.display.indexOf('-') !== -1) {
            return context.display.replace('-', '');
          } 
          return `-${context.display}`
        } 
      }),
   }
}
Enter fullscreen mode Exit fullscreen mode

The toggleSign action just toggles the minus sign in front of the current operand, held by the display property in context.

This step didn't feel very challenging, it was relatively easy to add this new piece of functionality. The only thing I had to be careful about was to make sure I was covering the TOGGLE_SIGN event in all the necessary states. While doing some ad hoc testing for this I uncovered that the percentage event wasn't handled when the state was in operand2 or negative_number_2, and I added handling for that event as well.

2. Raising the difficulty level: implementing operations history

The way I implemented the history feature is by replacing what the user sees with a string that concatenates all the user operations until equals or the percentage button is clicked.
This feature was pretty challenging to implement because it involved handling for almost all states and transitions, and there were many. To get an idea, here are the types for the Calculator machine states and events.
Path src/machine.types.ts

 interface CalStateSchema {
  states: {
    start: {};
    operand1: {
      states: {
        zero: {};
        before_decimal_point: {};
        after_decimal_point: {};
      };
    };
    negative_number: {};
    operator_entered: {};
    operand2: {
      states: {
        zero: {};
        before_decimal_point: {};
        after_decimal_point: {};
      };
    };
    negative_number_2: {};
    result: {};
    alert: {};
  };
}
type EventId = "NUMBER" 
 | "OPERATOR"
 | "TOGGLE_SIGN" 
 | "PERCENTAGE" 
 | "CLEAR_ENTRY"
 | "DECIMAL_POINT"
 | "CLEAR_EVERYTHING"
 | "EQUALS";

export interface CalEvent {
  type: EventId;
  operator?: string;
  key?: number;
}
Enter fullscreen mode Exit fullscreen mode

I started by adding a new property in context called historyInput that would hold the string of user operations:

const calMachine = Machine<Context, CalStateSchema, CalEvent>(
  {
    id: 'calcMachine',
    context: {
      display: '0.',
      operand1: undefined,
      operand2: undefined,
      operator: undefined,
      historyInput: '0.'
    },
  ...
Enter fullscreen mode Exit fullscreen mode

In the beginning I thought maybe I could target existing actions and just add a change to the historyInput as well.
Like this:
path: src/machine.ts

   ...
  const calMachine = Machine<Context, CalStateSchema, CalEvent>(
  ...,
    states: {
      start: {
        on: {
          NUMBER: [
            {
              cond: 'isZero',
              target: 'operand1.zero',
              actions: ['defaultReadout'],
            },
            ...
          ],
        ...
        },
      },
  {
   ...
   actions: {
   defaultReadout: assign({
        display: () => '0.',
        historyInput: () => '0.'
      }),
   }
  }
...
Enter fullscreen mode Exit fullscreen mode

But while this approach would work for some of the states and transitions, it didn't apply to all of them because historyInput needs to keep track of more than one number. And soon enough it became too hard for me to keep track of what should update when.
I then thought why not subscribe to the service and listen to changes? Instead of adding the historyInput in the Calculator machine, I would make a custom hook that returns the historyInput to the Calculator UI.
To get an idea of where I was going with this, here is a piece of the code:
path: src/useRecordComputationsHistory.ts

import  {useEffect} from 'react';
let history = '';
let lastOperator = '';
let isLastNumberNegative = false;

export default function useRecordComputationsHistory(service) {
    useEffect(() => {
        const subscription = service.subscribe((state) => {
          // simple state logging
          console.log(state.event, state.value);
          if(state.event.type === 'NUMBER') {
            //todo handle number starting with zero
            if(!isLastNumberNegative) {
              history = history+state.event.key;
            } else {
              const lastOperatorIndex = history.lastIndexOf(lastOperator);
              history = history.slice(0,lastOperatorIndex+1)+" "+state.event.key;
              isLastNumberNegative = false;
            }
          } else if(state.event.type === 'DECIMAL_POINT' && history.lastIndexOf('.') !== history.length - 1) {
            history = history+'.'
          }
           else if(state.event.type === 'OPERATOR' && history.trim().lastIndexOf(state.event.operator) !== history.trim().length - 1) {
            history = history+" "+state.event.operator+ " ";
            lastOperator = state.event.operator;
           }
           else if(state.event.type === 'PERCENTAGE') {
            history = history+'%';
            lastOperator = '%';
           }
           else if(state.event.type === 'TOGGLE_SIGN' && (typeof state.value === 'string' && state.value.indexOf('negative_number') > -1)) {
            const lastOperatorIndex = !!lastOperator ? history.lastIndexOf(lastOperator) : 0;
            isLastNumberNegative = true;
            history = lastOperatorIndex ? history.slice(0,lastOperatorIndex+1)  +" "+ `(-${history.slice(lastOperatorIndex+1)})` : `(-${history.slice(0,history.length)})`
           }
           else if(state.event.type === 'TOGGLE_SIGN' && (typeof state.value === 'string' && state.value.indexOf('negative_number') === -1)) {
             isLastNumberNegative = false;
           }
           else if((state.event.type === 'EQUALS' && (typeof state.value === 'string' && state.value.indexOf('result') !== -1)) || state.event.type === 'CLEAR_EVERYTHING') {
             history = '';
             lastOperator = '';
             isLastNumberNegative = false;
           }
           else if(state.event.type === 'CLEAR_ENTRY' && !(typeof state.value === 'string' && state.value.indexOf('operator_entered') !== -1)) {
            const lastOperatorIndex = !!lastOperator ? history.lastIndexOf(lastOperator) : 0;
            history = !lastOperatorIndex ? '' : `${history.slice(0,lastOperatorIndex+1)}`   
            // todo: handle percentage case, it should clear the last percentage entry
           }
        });

        return subscription.unsubscribe;
      }, [service]); // note: service should never change
      return history;
}
Enter fullscreen mode Exit fullscreen mode

Path: src/Calculator.tsx

...
import useRecordComputationsHistory from './useRecordComputationsHistory';

const Calculator = () => {
  const [state, sendMachine, service] = useMachine(machine, {});
  const history = useRecordComputationsHistory(service);

   ...
      <div>
        <span>{history} </span>
        <Input
          type="text" 
          value={state.context.display}
Enter fullscreen mode Exit fullscreen mode

As you can imagine it quickly became an if else soup, hard to manage and I was still not done covering all the cases. I decided to go back to the first approach, but this time around I had a better understanding of what needed to change where and I started adding new actions to handle just the historyInput change. This felt good, no more if else soup, and I was more confident that I could avoid invalid states with more ease.
Because the code is lengthy I will share a reference to the github commit with this change.

3. Next up: Adding unit tests

I added some unit tests using Jest to gain even more confidence that the changes I've added were stable and were covering all the use cases. I added tests for the pure functions as well as for the calculator machine itself.
I also replaced the hard coded strings for events and states with enums.
Here is a small sample of the unit tests I've added:
Path: src/machine.test.ts

    ...
    describe("convertNumberToPositiveInHistory", () => {
  test("given operation 1. + (-2.), last number should be converted to positive", () => {
    const givenHistory = "1. + (-2.)";
    const result = convertNumberToPositiveInHistory(givenHistory);
    expect(result).toEqual("1. + 2.");
  });
  test("given number (-1.), it should be converted to positive", () => {
    const givenHistory = "(-1.)";
    const result = convertNumberToPositiveInHistory(givenHistory);
    expect(result).toEqual("1.");
  });
  test("given number 1., it should do nothing", () => {
    const givenHistory = "1.";
    const result = convertNumberToPositiveInHistory(givenHistory);
    expect(result).toEqual("1.");
  });
});
    ...
    describe("calculator Machine scenario 3: operation with multiple operators 1 - 1 + 2 / 2", () => {
  const machine = interpret(calMachine);
  machine.start();
  machine.send({ type: E.NUMBER, key: 1 });
  machine.send({ type: E.OPERATOR, operator: "-" });
  machine.send({ type: E.NUMBER, key: 1 });
  machine.send({ type: E.OPERATOR, operator: "+" });
  machine.send({ type: E.NUMBER, key: 2 });
  machine.send({ type: E.OPERATOR, operator: "/" });
  machine.send({ type: E.NUMBER, key: 2 });
  const result = machine.send({ type: E.EQUALS });
  expect(result.context.historyInput).toEqual("1.");
  expect(result.context.display).toEqual("1.");
});
    ...
Enter fullscreen mode Exit fullscreen mode

Adding unit tests helped me uncover more bugs and fixing them felt easy because I was working with small pure functions and by now I had a better understanding of the states and transitions.

Play with the final version

4. Insights

All I have described in this article took me about a week to implement. I have to mention that it was my first try to actually work with xState. I've done some tutorials and courses previously and I was comfortable with the concepts and the syntax.

xState Visualizer

At first look into the xState Visualizer for the Calculator Machine I felt overwhelmed by all the details represented in the xstate machine. Here is the representation with the features I've added. But when I was done with this exercise it made a lot more sense to me and I realised it would be a valuable tool to maintain the codebase over time.
I was also amazed with the xstate/inspector that allowed me to use the calculator UI and see the equivalent changes in the Visualizer. States, events, context, even sequence of events I could follow what was happening in the xState machine step by step.

Scalability Considerations

I've kept all the logic in a single file for the calculator machine, but the file grew to almost 600 loc.
If I were to make a scalable project I would probably split it into multiple files. For example all the history related logic could be moved in a separate file. I would also make separate files for guards, actions and state transitions.

Adding enums for states, events, even guards and actions helps to avoid typos, while keeping a single reference through out the codebase for each of them is easier to refactor over time.
I added more types for states and events, which seems to also give me more confidence that I was referencing the states and events correctly.

Other things to consider are changes in the state machine structure when adding new functionality or changing an existing one. Of course this could apply to any codebase, not just one using state machines. For example if I want to add a delete input button, or a result field that updates when the operation is valid, I couldn't say the answer is not straightforward given current implementation.

Another issue I found repeatedly is missed handling of events for some states. For example operand1 and negative_number have similar handling for some events, as well as operand2 and negative_number_2. It is easy to add logic in one place and forget about the other. Writing tests helps.

Automated tests

I liked that I could separate the calculator logic from the React component, so that I can test more use cases independently from the UI.
Also, if in the future I would like to switch to a different UI framework, I would be able to keep a lot of the tests that I've written as well as the state machine logic.

Another tool that seems promising is @xstate/test that generates automated tests using the model-based testing concept.

The end

This was quite an adventure, there was a lot to cover.
I've learned a lot and I hope this article helped you in some way as well :)

How did you find this article?
Would you have gone with a different approach for adding the history feature?
Would you like to try adding more features :D ?

Next step for me is to dive deeper into more complex xState examples.

Resources

Thank you for reading!

Discussion (0)