0
\$\begingroup\$

I've been learning React for about a month now. I wrote a small interactive calculator to show the effects of time dilation under special relativity. It's Node/React/Razzle(for SSR)/Typescript/Javascript.

Here are the issues I have run into and would like help with:

  1. I feel like I'm abusing state. You change one parameter and it cascades through other dependent values which then trigger a recalculation; but because state updates are asynchronous, I have to set awkward artificial callbacks to force a timely update.

  2. Bootstrap prepend and append on input forms take up a lot of space and cause certain calculators to render poorly on mobile... Should I consider using Material UI, or can I "prettify" and resize the input fields appropriately with custom CSS / bootstrap?

  3. Scalability - Currently there are only ~13 components but intend to make a lot more pages and calculators, and I'm wondering if there's any room for refactoring to take advantage of recurring patterns: it feels like I could be writing a lot less code. Also, routes - the file containing routes could get huge - will that become a problem in the future?

import React, { useEffect, Component } from "react";
import { ThreeColumnContainer } from "../Layout/ThreeColumnContainer";
import { TextControl } from "../controls/Input";
import { Helmet } from "react-helmet";

const c = 299792.458; //km/s

export class TwinParadoxUnderSpecialRelativity extends Component<{}, { [key: string]: number }> {

  constructor(props) {
    super(props);
    this.state = { 
      velocityAsPercentageOfC: 0,
      lorentzFactor: 1,
      velocityInKph: 0,
      velocityInMph: 0,
      contractedElapsedTime: 1,
      dilatedElapsedTime: 1 
    };

    this.calculateLorentzFactor = this.calculateLorentzFactor.bind(this);
    this.updateVelocity = this.updateVelocity.bind(this);
    this.updateLorentzFactor = this.updateLorentzFactor.bind(this);
    this.calculateVelocityAsPercentageOfC = this.calculateVelocityAsPercentageOfC.bind(this); 
    this.calculateTimeDilation = this.calculateTimeDilation.bind(this);
    this.updateElapsedTime = this.updateElapsedTime.bind(this);
    this.updateTravelerElapsedTime = this.updateTravelerElapsedTime.bind(this);
    this.calculateTimeContraction = this.calculateTimeContraction.bind(this); 
  }



  updateVelocity(e) {
    var velocityAsPctOfC = e.target.value;
    var lorentzF = this.calculateLorentzFactor(velocityAsPctOfC);
    var kph = this.convertToKmh(velocityAsPctOfC);
    var mph = this.convertToMph(kph)
    //var dilatedTime = 
    this.setState({ velocityAsPercentageOfC: velocityAsPctOfC, lorentzFactor: lorentzF, velocityInKph: kph, velocityInMph: mph},
      () => this.setState({dilatedElapsedTime: this.calculateTimeDilation(this.state.contractedElapsedTime, lorentzF)}));
  }

  updateLorentzFactor(e) {
    var lorentzF = e.target.value;
    var velocityAsPctOfC = this.calculateVelocityAsPercentageOfC(lorentzF);
    var kph = this.convertToKmh(velocityAsPctOfC);
    var mph = this.convertToMph(kph)
    //var dilatedTime = this.calculateTimeDilation(this.state.elapsedTime);
    this.setState({ velocityAsPercentageOfC: velocityAsPctOfC, lorentzFactor: lorentzF, velocityInKph: kph, velocityInMph: mph},
      () => this.setState({dilatedElapsedTime: this.calculateTimeDilation(this.state.contractedElapsedTime, lorentzF)}));
  }

  updateTravelerElapsedTime(e) {
    var elapsed = e.target.value;
    this.setState({contractedElapsedTime: elapsed},
      () => this.setState({dilatedElapsedTime: this.calculateTimeDilation(this.state.contractedElapsedTime, this.state.lorentzFactor)}));
  }

  updateElapsedTime(e) {
    var dilatedTime = e.target.value;
    this.setState({ dilatedElapsedTime: dilatedTime },
     () => this.setState({contractedElapsedTime: this.calculateTimeContraction(this.state.dilatedElapsedTime, this.state.lorentzFactor)}));
  }

  calculateLorentzFactor(velocityAsPercentageOfC: number) {
    return 1 / Math.sqrt( 1 - ( Math.pow( velocityAsPercentageOfC / 100, 2) ) );
  }

  calculateVelocityAsPercentageOfC(lorentzFactor: number) {
    return Math.sqrt(-1 * (Math.pow(1/lorentzFactor, 2) -1)) * 100;
  }

  convertToKmh(velocityAsPercentageOfC: number){
    return velocityAsPercentageOfC * c;
  }

  convertToMph(kilometersPerHour: number)
  {
    return kilometersPerHour * 0.621371;
  }

  calculateTimeContraction(contractedElapsedTime: number, lorentzFactor: number)
  {
    return contractedElapsedTime / lorentzFactor;
  }

  calculateTimeDilation(dilatedElapsedTime: number, lorentzFactor: number)
  {
    return dilatedElapsedTime * lorentzFactor;
  }

  roundNumber = (num: number, dec: number) => {
    return Math.round(num * Math.pow(10, dec)) / Math.pow(10, dec);
  }


  renderLeft() {
    return (
      <div>
        <h1>Twin Paradox (Time Dilation under SR) Calculator</h1>
        <p/>
        <TextControl prepend="Relative velocity as % of c" type="number" value={this.state.velocityAsPercentageOfC} append="v" onChange={this.updateVelocity} />
        <TextControl prepend="Lorentz Factor" type="number" value={this.state.lorentzFactor} append="gamma" onChange={this.updateLorentzFactor} />
        <TextControl prepend="Velocity in kph" type="number" value={this.state.velocityInKph} append="kph" readonly="true" />
        <TextControl prepend="Velocity in mph" type="number" value={this.state.velocityInMph} append="mph" readonly="true" />
        <TextControl prepend="Earth Observer Elapsed Time" type="number" value={this.state.dilatedElapsedTime} append="Tb" onChange={this.updateElapsedTime} />
        <TextControl prepend="Spaceship Traveler Elapsed Time" type="number" value={this.state.contractedElapsedTime} append="Ta" onChange={this.updateTravelerElapsedTime}/>
        <p></p>
        <h4>Explanation</h4>
        <p>At {this.roundNumber(this.state.velocityAsPercentageOfC,0)}% of the speed of light (c), the lorentz factor or gamma is {this.roundNumber(this.state.lorentzFactor,4)}. That means a twin traveling through space will age at {this.roundNumber(1 / this.state.lorentzFactor * 100,4)}% the rate of its twin at relative rest on earth. If {this.roundNumber(this.state.dilatedElapsedTime,4)} units of time have passed on earth, {this.roundNumber(this.state.contractedElapsedTime,2)} units of time will pass for the space twin. For this to be realistic, the space twin needs to travel at {this.roundNumber(this.state.velocityInMph/1000,0)}K miles per hour ({this.roundNumber(this.state.velocityInKph/1000,0)}K kilometers per hour).</p>
        <p>Interestingly, the spaceship - and everything in it - will also contract in length to {this.roundNumber(1 / this.state.lorentzFactor * 100,2)}% of its original length.</p>
      </div >
    );
  }

  renderMiddle() {
    return (
      <div>
        <Helmet>
          <title>Time Dilation Calculator (Twin Paradox)</title>
          <meta name='description' content='Calculate the Lorenz factor based on given velocity or percentage of the speed of light to measure time dilation in special relativity.' />
        </Helmet>
        <h3>Calculate Time Dilation (SR)</h3>
        <p>Time Dilation is the time difference between two clocks or observers moving relative to each other. Time Dilation has practical consequences at very fast relative speeds, in particular for GPS and other satelites. The formula to calculate time dilation is Sqrt(1 - v^2 / c^2) where v is relative velocity and c is the speed of light. The result is called the Lorenz factor.</p>
        <p>Time Dilation means that someone traveling at very high speeds will "experience" time at a slower rate than someone in a frame at rest. So at speeds approaching the speed of light a traveler will age more slowly than someone observing from earth. The closer the traveler gets to the speed of light, the more dramatic the effect, so the traveler could experience and age only a year while everyone on earth ages and experiences eight years.</p>
      </div>
    );
  }

  render() {

    return (
      <div>
        <ThreeColumnContainer
          left={this.renderLeft()} middle={this.renderMiddle()}></ThreeColumnContainer>
      </div >
    );
  }
}
//ThreeColumnContainer.js
import React from 'react';

const designsThatWillAwe = require("../../images/WebsiteDesignsThatWillAwe.png");

export function ThreeColumnContainer(props) {
  return (
    <div className="row">
      <div className="col-6" id="left">
        {props.left}
      </div>
      <div className="col-sm" id="middle">
        {props.middle}
      </div>
      <div className="col-sm" id="right">
        <img src={designsThatWillAwe} alt="Fractal Flame Swirls - Designs That Will Awe"/>
      </div>
    </div>
  );
}
//TextControl.js
import React from 'react';

function inputControl(props) {
  if (props && props.onChange) {
    return (<input className="form-control" type={props.type} value={props.value} onChange={props.onChange} readonly={props.readonly}/>);
  }
  else{
    return (<input className="form-control" type={props.type} value={props.value} onChange={()=>{}} readonly={props.readonly}/>);
  }
}

export function TextControl(props) {
  return (
    <div className="input-group">
      {props.prepend &&
      <div className="input-group-prepend">
        <span className="input-group-text">{props.prepend}</span>
      </div> }
      {inputControl(props)}
      {props.append &&
      <div className="input-group-append">
        <span className="input-group-text">{props.append}</span>
        </div>
      }
    </div>
  );
}
\$\endgroup\$

1 Answer 1

2
\$\begingroup\$

Let me say first that I haven't actually tried the code or proposed changes, mostly because I don't have the imported files ThreeColumnContainer and TextControl.

I begin with what I think is the main challenge.


There are some interdependent values, and whenever one of them is changed by the user, all others should be updated instantaneously. This doesn't seem to be as straightforward as one may think in clean TypeScript/React.

  • Currently, the code is storing and processing strings of the input values, because e.target.value is a string. This implicitly relies on JavaScript's auto-conversion between strings and numbers, and is hidden by the fact that the e parameters implicitly have type any. If you give e an appropriate type (I guess e: React.ChangeEvent<HTMLInputElement>), then tsc will probably start complaining about string/number type mismatches.

  • As an alternative, one might actually store and process numbers, either by using e.target.valueAsNumber (which should work because the inputs have type="number"), or by explicitly parsing with parseFloat(e.target.value). That, however, may lead to strange behavior of the input fields. For example, we would probably not be able to append a decimal point at the end, because "123." would immediately be converted to and rendered as 123.

I see a couple of options to deal with this:

  1. Continue to store strings; adopt the component's state type (e.g. { [key: string]: string | number }) and add parseFloats where necessary.

  2. Store numbers, and additionally store the actual string value of the last-edited input field. On rendering, use the numeric value by default, or have that overridden by the stored string for one input field.

  3. Write an input component that doesn't cascade the values instantaneously, but only after the user "submits" the value (e.g., by pressing Return).

  4. Write an input component that does cascade the values instantaneously, but keeps an internal state of its value, which is updated from props only when not focused.

In a word: Set noImplicitAny to true in your tsconfig.json, and play around with these options to fix the new errors.


Some more fine-grained suggestions:

  • Use let or const instead of var.

  • Be more specific about the type of your component's state. For example:

    type State = {
      velocityAsPercentageOfC: number;
      lorentzFactor: number;
      velocityInKph: number;
      velocityInMph: number;
      contractedElapsedTime: number;
      dilatedElapsedTime: number;
    };
    
    // Or, saving on space:
    type State = {
      [X in "velocityAsPercentageOfC" | "lorentzFactor" | /* etc. */]: number;
    };
    
    export class TwinParadoxUnderSpecialRelativity extends Component<{}, State> {
      // ...
    }
    
  • Keep state minimal, and keep values in state orthogonal to each other. So instead of storing 4+2 interdependent values, try to store only 1+1 values and calculate all other values in the render methods. That shouldn't be a problem performance-wise here as the computations aren't too expensive. If they were, we could use memoization.

  • On your first question: React allows to give a callback to setState, the first parameter of which will be previous state. This lets us update state based on the current state without worrying about timing.

    updateVelocity = (e: React.ChangeEvent<HTMLInputElement>) => {
      // ...
    
      this.setState(prevState => ({
        velocityAsPercentageOfC: velocityAsPctOfC,
        lorentzFactor: lorentzF,
        velocityInKph: kph,
        velocityInMph: mph,
        // NOTE: Here contractedElapsedTime of previous state is used
        dilatedElapsedTime: this.calculateTimeDilation(prevState.contractedElapsedTime, lorentzF),
      }));
    }
    

    More on this can be found in the documentation.

  • For the purpose of formatting it isn't optimal to round the numbers. For example, Math.round(1.005 * Math.pow(10, 2)) / Math.pow(10, 2) == 1 (thanks https://stackoverflow.com/a/12830454 for the example), and there may be other artifacts of floating point representation. Instead, just use toLocaleString:

    roundNumber(num: number, dec: number) {
      return num.toLocaleString(undefined, { maximumFractionDigits: dec });
    }
    

    If wanted, you may also specify minimumFractionDigits.


  • Consider using class properties instead of calls to .bind() (some of which are needless anyways). Then you may even dissolve the constructor.

    export class TwinParadoxUnderSpecialRelativity extends Component<{}, State> {
      readonly state: State = {
        // ...
      };
    
      updateVelocity = (e: React.ChangeEvent<HTMLInputElement>) => {
        // ...
      }
    }
    

    Some pros and cons of class properties are discussed in https://stackoverflow.com/questions/50375440/binding-vs-arrow-function-for-react-onclick-event.

  • In TypeScript (and modern JavaScript), instead of Math.pow you can use the exponentiation operator **:

    calculateLorentzFactor(velocityAsPercentageOfC: number) {
      return 1 / Math.sqrt(1 - (velocityAsPercentageOfC / 100) ** 2);
    }
    
  • The utility functions (calculateLorentzFactor etc.) do not use this, so move them out of the class. Perhaps even move them to another file so that they may be reused more easily. The same may be done with const c. Also, I'd put the comment km/s into a JSDoc comment, so that in suited IDEs it appears on hover:

    /**
    * Speed of light in km/s.
    */
    const c = 299792.458;
    
  • Personally I'd use even more descriptive names in some cases. For instance, in code (as opposed to formulas), I'd write something like SPEED_OF_LIGHT instead of c, and kphToMph instead of convertToMph. Oh, and I find it useful to prefix names of event handlers with "handle", e.g. handleUpdateVelocity instead of updateVelocity.

  • Especially if you work with others, please follow a code style. Auto-formatters may help. In the code sample, there are some superfluous blank lines, inconsistent brace styles, and inconsistent and sometimes confusing spacing. Also, you may consider breaking up long lines:

    renderLeft() {
      return (
        <div>
          <h1>Twin Paradox (Time Dilation under SR) Calculator</h1>
    
          <p />
    
          <TextControl
            prepend="Relative velocity as % of c"
            type="number"
            value={this.state.velocityAsPercentageOfC}
            append="v"
            onChange={this.updateVelocity}
          />
    
          {/* etc. */}
    
          <p>
            At {this.roundNumber(this.state.velocityAsPercentageOfC, 0)}% of the speed of light (c),
            the lorentz factor or gamma is {this.roundNumber(this.state.lorentzFactor, 4)}.
    
            {/* etc. */}
          </p>
    
          {/* etc. */}
        </div>
      );
    }
    
  • If a JSX tag doesn't have children, use auto-closing:

    render() {
      return (
        <div>
          <ThreeColumnContainer
            left={this.renderLeft()}
            middle={this.renderMiddle()}
          />
        </div>
      );
    }
    
  • You write readonly="true". I don't know about the interface of TextControl, but if it's accepting booleans, then readonly should be enough.

  • Perhaps it's because of some omitted code, but there's an unused import of useEffect.

  • As an aside, there's the <sup> tag for prettier rendering of superscripts/exponentiation.


And finally to your questions:

  1. (Should be answered above.)
  2. I can't fully answer that because I have never used Material UI. Anyways, perhaps you could try to use just forms and labels instead of prepend/append, or to shorten the prepend text (e.g. "kph" instead of "Velocity in kph"). In general, if don't use it already, you may find React Bootstrap to be a useful library.
  3. I don't see much to extract in your code sample. One thing perhaps, if you plan on using numeric inputs more often, is to write a component specifically for that, so that you can avoid repeating type="number", and so that the onChange handler tells you the new value parsed as number (= you don't have to deal with e.target.value all the time). On the issue of routes, it's hard to tell without seeing code, but I think it's not unusual that route files become somewhat large. As long as you keep that clean and focussed, it shouldn't be a problem.
\$\endgroup\$
1
  • \$\begingroup\$ I wrote an alt implementation in pure JS/HTML as an exercise, and this review hits all of the points I ran into and more! Such an excellent review I hope it gets many upvotes! \$\endgroup\$
    – morbusg
    Commented May 17, 2021 at 14:45

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.