Software Engineering at Amity — Pandemic Edition Part II

More lessons learned during a busy few months.

September 21, 2020
By
Chris Vibert

Part II in the series.

During the few months that I stood at my desk in the kitchen, I worked on three projects, all with very different tech stacks. I wrote some good code, and I wrote some bad code. I lost what feels like years of sleep, but more importantly, I gained what feels like years of experience.

I want to share some of these experiences and pass on some useful tips that I picked up along the way.

Build your translation service

Before moving to Bangkok earlier this year, I’d never worked on an application that needed to support more than one language — hardcoding text in English was all I’d ever known.

At Amity, we need to support many languages, and we do so mainly using the react-intl library. It’s simple to use, but until my second ‘pandemic project,’ given the immense time pressure, I’d never given much thought to how it works.

This second project was an application for Doctors to manage potential COVID-19 patients (built with NextJS, which I talk about in Part I). We knew that the application would eventually have to support the Thai language, but we started with just hardcoded English to move quickly.

When the time for translation came, I took the same approach always:



Image for post

This led me to more bug reports than I was expecting — many people were having trouble translating their NextJS apps with the popular libraries like react-intl and react-i18next, mainly due to the server-side rendering (SSR) that comes with NextJS.

I found the next-i18next library specifically for NextJS and its SSR, but that required a custom Express server that we were intentionally avoiding in favor of the far simpler API routes solution.

And so I decided to build my own. Doing so was a last resort, but I came up with this solution due to the immense time pressure. Even my Sunday morning Git commits are evidence of the rush:

Image for post

Building a translation service that matched our needs was relatively straightforward:

  1. Detection of the user’s language - browser settings can provide an initial language with window.navigator.language, and localStorage can be used to save a preferred language.
  2. A React context provider that stores the user’s current language and a function to update this language. The function to update language is needed only by the ‘language picker’ component. Meanwhile, the language value itself is needed to ensure that our ‘translate’ function is outputting a string in the correct language. This translate function comes from…
  3. A React hook to consume the current language from context and provide a translate function to components. The function will take a translation key and return the corresponding value from the relevant language object. If there is no value, it’ll try the default language object, and if no luck here, it’ll only return the key itself.

{% c-block language="javascript" %}
const defaultLanguage = 'en';

// All the translation key/values for each supported language.
// This gets pretty big so best to keep it in a separate file.
const translations = {
 en: {
   'general.home': 'Home',
   'general.chat': 'Chat',
   ...
 },
 th: {
   'general.home': 'หน้าหลัก',
   'general.chat': 'แชท',
   ...
 }
}

export const useTranslate = () => {
 // Get the current locale value from context.
 const { locale } = useLocaleContext();

 // The translate function for components to use.
 const translate = key => (
   translations[locale][key] ||
   translations[defaultLanguage][key] ||
   key;
 )

 return { translate };
};
{% c-block-end %}

Any component rendering a string that needs translation can use this hook to access the translate function:

{% c-block language="javascript" %}
import React from 'react'
import { useTranslate } from 'locale/utils/useTranslate.js'

const ComponentUsingTranslate = () => {pp
 const { translate } = useTranslate()

 return (
<div>
<h1>{translate('general.home')}</h1>
<button>Click to get started</button>
</div>
 )
}

export default ComponentUsingTranslate;
{% c-block-end %}

Tips for translating apps

A general piece of advice: don’t let translation or localization become an afterthought. If you eventually need to translate your app, you should build for this right away.

Even without any translation system in place, you can wrap text strings in a ‘dummy’ translate function:

{% c-block language="javascript" %}
const translate = string => string
<span>{translate('This will be translated one day')}</span>
{% c-block-end %}

Doing so will allow you to find later the strings that require translation by searching for where you use this function. When the real translate function comes along — assuming it’s named the same — you can just replace these text strings with the corresponding key from the translations object.

Rediscovering higher-order components in React

Since the release of Hooks, React applications have started to look very different. One of the main differences is that ‘with’ has been replaced by ‘use.’ By this, I mean that higher-order components have been replaced with hooks.

Both hooks and higher-order components (HOCs) allow you to extract common logic for reuse across multiple components. Whether or not hooks can and should completely replace HOCs is debatable, but the reality is that hooks are replacing HOCs. Think of all your favorite libraries, and you’ll find that they’re probably now advocating the use of hook alternatives like useSelector and useHistory.

Despite this trend, the first of my three projects was working on an application using React 15 with no hooks but lots of HOCs — the application was built from functional components wrapped by HOCs from the recompose library. This HOC-based architecture turned out to be a perfect solution to our requirement: to take the existing registration flow from Amity’s Eko App and modify it to help a few environment variables. It could also act as the registration flow for a completely new app.

We knew that this would mean lots of conditional logic throughout the app:

{% c-block language="javascript" %}
const variableToUse = config.isApplicationA ? variableA : variableB
{% c-block-end %}

where this variableToUse could be anything such as a page title, the current registration step number, or the URL of the next step in the registration flow.

Instead of having to write this line of code in each component, HOCs allowed us to:

  1. Write it only once in a withIsApplicationA HOC
  2. Keep all of the conditional logic outside of the components themselves. The components would receive these variables as props but without knowing how or where their values were derived.

Point two was particularly important because we knew that eventually, the two registration flows might diverge enough to justify each having its repository. If this time came, we could remove the conditional logic without interfering with the components themselves.

As the application progressed, these conditional variables became more complex. Soon, each of the registration flows was using a different set of backend APIs with very different response formats. This meant that the frontend needed a separate set of MobX store actions for each set of APIs.

For example, the final registration request required a separate store action for each registration flow:

{% c-block language="javascript" %}
@action
registerAppA(formData) {
 const url = 'app-a/register';
 // POST and update store according to response
}

@action
registerAppB(formData) {
 const url = 'app-b/register';
 // POST and update store according to response
}
{% c-block-end %}

We built the WithConditionalRequest HOC to inject the relevant store action as a prop into the wrapped component to handle this. It does this by:

  1. Consuming the app’s MobX store and the app’s config — inject('store', 'config')
  2. Using this config to determine the relevant app — withIsApplicationA(config)
  3. Taking the two possible MobX actions from the store based on the function names passed as arguments (and checking that they exist)

Choosing the correct one of these two and passing it down as a prop named according to the propName argument

{% c-block language="javascript" %}
import { compose, withProps, setDisplayName } from 'recompose';
import { inject } from 'mobx-react';
import { withIsApplicationA } from '/.withIsApplicationA'

/**
* HOC to inject the appropriate request action from the store.
* @param {String} propName name of the prop that will be injected
* @param {String} trueRequestName name of function to inject if condition is true
* @param {String} falseRequestName name of function to inject if condition is false
*/
const WithConditionalRequest = ({ propName, trueRequestName, falseRequestName }) => {
 return compose(
   setDisplayName('WithConditionalRequest'),
   inject('store', 'config'),
   withIsApplicationA(config),
   withProps(({ store, isApplicationA }) => {

     // Take the MobX store action for each request.
     const trueRequest = store[trueRequestName];
     const falseRequest = store[falseRequestName];

     if (typeof trueRequest !== 'function' || typeof falseRequest !== 'function') {
       throw new Error('One of the arguments provided to WithConditionalRequest is not a store action.');
     }

     // uses isApplicationA returned from the withIsApplicationA HOC.
     const finalRequest = isApplicationA ? trueRequest : falseRequest;

     return { [propName]: finalRequest };
   }),
 );
};

export default WithConditionalRequest;
{% c-block-end %}

Using this conditional request, HOC, along with many other HOCs, both custom and third-parties, we established a common shape for our components: small, functional, and injected with a few props dependent on which registration flow it was.

This pseudo example of the final registration step demonstrates shape. The last step looks nearly identical for each registration flow for the user but has a different title and uses a different API for the registration request thanks to the withAppName and withConditionalRequest HOCs.

{% c-block language="javascript" %}
import { withRouter } from 'react-router-dom'
import { compose } from 'recompose'
import { withAppName, withConditionalRequest } from 'my-hocs'

const FinalRegistrationStep = ({ appName, registerRequest, history }) => {

 const handleRegister = () => {
   registerRequest().then(() => history.push('/complete'))
 }

 return (
<div>
<h1>{`Nearly registered for ${appName}!`}</h1>
<button onClick={handleRegister}>
       Register
</button>
</div>
 )
}

export default compose(
 withAppName,
 withConditionalRequest({
   propName: 'registerRequest',
   trueFunction: 'registerAppA',
   falseFunction: 'registerAppB',
 }),
 withRouter,
)(FinalRegistrationStep)
{% c-block-end %}

The component itself remains very simple or ‘dumb’ — it doesn’t even know that it’s acting as a final step for two separate registration flows. This kind of simplicity cannot even be achieved using hooks.

Using hooks, this final step component would probably look something like this:

{% c-block language="javascript" %}
import { useHistory } from 'react-router-dom'
import { useAppName, useConditionalRequest } from 'my-hooks'

const FinalRegistrationStep = ({ appName, registerRequest, history }) => {

 const appName = useAppName()
 const registerRequest = useConditionalRequest('registerAppA', 'registerAppB')
 const history = useHistory()

 const handleRegister = () => {
   registerRequest().then(() => history.push('/complete'))
 }

 return (
<div>
<h1>{`Nearly registered for ${appName}!`}</h1>
<button onClick={handleRegister}>
       Register
</button>
</div>
 )
}
{% c-block-end %}

The conditional logic lives inside the component itself — the withAppName and withConditionalRequest hooks. If the time came to refactor and remove this logic, it would mean doing so within the components themselves.

Overall, this project was a refreshing reminder that there is still a place for HOCs in React applications, no matter what version of React is being used.

Tips for using higher-order components

A definite downside of a HOC-based architecture is that you end up in ‘wrapper hell’. This is where components become so buried under layers of HOCs that your React DevTools and stack traces become meaningless.

Wrapper hell.


To make this more manageable, add a display name to your custom HOCs. All the recompose HOCs will have this built-in, and recompose even offers methods to set display names yourself: setDisplayName and wrapDisplayName. It won't solve the problem, but it’ll alleviate some 542 6pain when debugging a stack trace.

Join the Amity team!

We’re always seeking ambitious, passionate and community-driven candidates to join us.