Introduction

This post covers the functionality of the useComponents hook that we use all across Awayto. It is with the goal of education, understanding — and uncovering veils of supposed high-tech — that we dive into topics like how our useComponents feature works. Making simple utilities like this gives us a chance for quick and easy hands-on experience using technologies we might not otherwise.

useComponents’s value to productivity for us is in automatic discovery and loading of React components based on file system changes during development. We apply simple concepts using a combination of Awayto’s underlying frameworks to create a utility that is convenient for developers and utilizes powerful (and super cool!) features of JavaScript and React. Of note, we’ll be using react-app-rewired, JavaScript’s Proxy and Reflect APIs, and React’s Lazy and Suspense APIs.

The Component List

In order for this to work, we need to have a list of our components and their locations in the file system. As well, this all needs to be performed at build time, so we can make use of it as developers. We use create-react-app as a base for the react application, and to hook into the webpack build portion of CRA, we use react-app-rewired. Among other things, rewired allows us to access the express dev server lifecycle when files are updated. So our task is to simply get a list of files we care about, and save them somewhere that our hook can access. We can see the specifics in config-overrides.js.

We declare a couple functions responsible for building the object we want to work with in our hooks and elsewhere. buildPathObject builds an object for a single component and its file path. We feed this into the second function, parseResource, which globs any given path we need to be observing on file system changes, and reduces all the path’s files down. Finally, checkWriteBuildFile performs the check for diffs in the build file and rewrites the file as needed.

/**
 * 
 * @param {string} n A path name returned from glob.sync
 * @returns An object like `{ 'MyComponent': 'common/views/MyComponent' }`
 */
const buildPathObject = n => ({ [`${n[n.length - 1].split('.')[0]}`]: `${n[n.length - 3]}/${n[n.length - 2]}/${n[n.length - 1].split('.')[0]}` }) // returns { 'MyThing': 'common/views/bla' }

const filePath = path.resolve(__dirname + AWAYTO_WEBAPP + '/build.json');
const globOpts = {
  cache: false,
  statCache: false
};

try {
  if (!fs.existsSync(filePath))
    fs.closeSync(fs.openSync(filePath, 'w'));
} catch (error) { }

/**
 * 
 * @param {string} path A file path to a set of globbable files
 * @returns An object containing file names as keys and values as file paths
 * ```
 * {
 *   "views": {
 *     "Home": "common/views/Home",
 *     "Login": "common/views/Login",
 *     "Secure": "common/views/Secure",
 *   },
 *   "reducers": {
 *     "login": "common/reducers/login",
 *     "util": "common/reducers/util",
 *   }
 * }
 * ```
 */
function parseResource(path) {
  return glob.sync(path, globOpts).map((m) => buildPathObject(m.split('/'))).reduce((a, b) => ({ ...a, ...b }), {});
}

/**
 * We keep a reference to the old hash of files.
 */
let oldHash;

/**
 * This function runs on build and when webpack dev server receives a request.
 * Scan the file system for views and reducers and parse them into something we can use in the app.
 * Check against a hash of existing file structure to see if we need to update the build file. The build file is used later in the app to load the views and reducers.
 * 
 * @param {app.next} next The next function from express app
 */
function checkWriteBuildFile(next) {
  try {
    const files = JSON.stringify({
      views: parseResource('.' + AWAYTO_WEBAPP_MODULES + '/**/views/*.tsx'),
      reducers: parseResource('.' + AWAYTO_WEBAPP_MODULES + '/**/reducers/*.ts')
    });

    const newHash = crypto.createHash('sha1').update(Buffer.from(files)).digest('base64');

    if (oldHash != newHash) {
      oldHash = newHash;
      fs.writeFile(filePath, files, () => next && next())
    } else {
      next && next()
    }
  } catch (error) {
    console.log('error!', error)
  }
}

With these functions in place, we can run the check before we export to instantiate the build. Then we hook into the express dev server config, and check when files are changed, but before responding to the browser session.

checkWriteBuildFile();

module.exports = {

  // ... other config here ...

  devServer: function (configFunction) {
    return function (proxy, allowedHost) {
      const config = configFunction(proxy, allowedHost);
      config.before = function (app, server, compiler) {
        app.use(express.static(__dirname + AWAYTO_CORE, {
          etag: false
        }));
        app.use((req, res, next) => {
          checkWriteBuildFile(next)
        });
      }
      return config;
    };
  },

  // ... other config here ...

}

Now any time we start up the dev server and start editing files, we’ll have the file app/src/build.json which gives us something like:

{
  "views": {
    "AsyncAvatar": "common/views/AsyncAvatar",
    "ConfirmAction": "common/views/ConfirmAction",
    // truncated for blog
    "ManageUsers": "manage_users/views/ManageUsers",
    "Manage": "manage/views/Manage",
    "CompleteSignUp": "profile/views/CompleteSignUp",
  },
  "reducers": {
    "login": "common/reducers/login",
    "util": "common/reducers/util",
    "manageGroups": "manage_users/reducers/manageGroups",
    "manageRoles": "manage_users/reducers/manageRoles",
    "manageUsers": "manage_users/reducers/manageUsers",
    "profile": "profile/reducers/profile"
  }
}

Looks nice and simple. We’re just looking at the views in this post, so we’ll now see how they’re used in Awayto’s useComponents hook. All the hooks can be found in the src/webapp/hooks directory with some info in the readme.

The Hook

The hook should allow us to load components as they are entered into the file system, and with some kind of fault tolerance. Awayto’s module structure wants us to keep our code somewhat atomic in a sense, but this isn’t always possible. So, if a module depends on another that happens to not be there, we shouldn’t go breaking everything. Let’s look at how we can use the orchestra of Proxy, Reflect, Suspense, and Lazy, to make this happen.

// src/webapp/hooks/useComponents.ts

import { createElement, useMemo, lazy } from 'react';
import { IBaseComponent, IBaseComponents, LazyComponentPromise } from '../';

import build from '../build.json';

const { views } = build as Record<string, Record<string, string>>;

const components = {} as IBaseComponents;

/**
 * `useComponents` takes advantage of [React.lazy](https://reactjs.org/docs/code-splitting.html#reactlazy) as well as the [Proxy API](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Proxy). By combining these functionalities, we end up with a tool that seamlessly meshes with expanding and changing codebases.
 * 
 * As new files are added to the project while the developer is working, they will be automatically discovered and made lazily available through `useComponents`. The only need is to refresh the browser page once the component has been added to the render cycle.
 * 
 * ```
 * import { useComponents } from 'awayto';
 * 
 * const { AsyncAvatar } = useComponents();
 * 
 * return <AsyncAvatar {...props} />;
 * ```
 * 
 * @category Hooks
 */
export function useComponents(): IBaseComponents {
  
  const comps = useMemo(() => new Proxy(components, {
    get: function (target: IBaseComponents, prop: string): IBaseComponents {
      if (!components[prop]) {
        components[prop] = lazy((): LazyComponentPromise => import(`../modules/${views[prop]}`) as LazyComponentPromise);
      }
      
      target[prop] = views[prop] ? components[prop] : ((): JSX.Element => createElement('div')) as IBaseComponent

      return Reflect.get(target, prop) as IBaseComponents;
    }
  }), []);

  return comps;
}

The Proxy API really makes this whole concept come together, as it provides the dynamic context necessary. In our case, proxy resolves an object which we control with regard to attribute instantiation. Here the get method we define as part of Proxy’s options gives us the tie in to check whether or not we have lazily loaded our component, and if not store it on the application layer. Then we retrieve the component, or return an empty div (meaning what the developer has requested is not found in the file system by that file name). Finally, in conjunction with Proxy, we use Reflect to continue the attribute’s instantiation.

Here are some other things we had to consider when making this feature:

Dynamic Access

In order to call a component, the developer simply needs to know the file name of the component in question, which by Awayto convention is the name of the component itself. With this name, the dev can open a call to useComponents and pull out any component they desire, or fail gracefully.

Lazy Splits

Components used in this manner should make use of lazy loading in order to utilize code splitting in a simple and effective way.

Type Bound

This aspect is more to regard how the components themselves are structured. We need type-bound functionality across the application, however the dynamic import does not allow us a trivial way to pass type information in a lazy way (at least unbeknownst to this author). So we decided that IProps would be a globally accessible type, and should allow for props to act on multiple types when necessary:

declare global {
  interface IProps {
    image?: string;
  }
}

export function AsyncAvatar ({ image }: IProps): JSX.Element {
  const [url, setUrl] = useState('');
  const fileStore = useFileStore();
  
  useEffect(() => {
    async function getImage() {
      if (fileStore && image) {
        setUrl(await fileStore?.get(image));
      }
    }
    void getImage();
  }, [fileStore, image])

  return url.length ? <Avatar src={url || ''} /> : <></>
}

export default AsyncAvatar;

We end up with component definitions like so, where IProps must accept the desired props optionally. If image doesn’t get that ? in IProps, we’ll throw errors. This is at least declarative in nature, keeps types with their corresponding components, all while keeping us happy in Typescript world, without opening the react-types jar.

In Use

Here is our final use case for useComponents using our AsyncAvatar.

import React from 'react';
import { useComponents } from 'awayto-hooks';

export function SomeParentComponent (props: IProps): JSX.Element {
  
  const { AsyncAvatar } = useComponents();

  return <AsyncAvatar image="some/url/location" {...props} />
}

export default SomeParentComponent;

Conclusion

We have covered the interworkings of the useComponents hook of the Awayto framework. This hook allows us to take advantage of file system reload opportunities and asynchronous-based APIs that support lazy-loaded and code-split components. Just a few lines of code are needed to create this slight enhancement in our workflow, and the value output should allow for faster development. If you’ve made it this far, we hope you have enjoyed or taken something positive from the example. The concept is so simple, you should be able to implement it in your own framework with relative ease. As well, check out the Awayto framework or the video for a full package web application framework; open-source in its own right, and built ontop of AWS.

Thanks for your time!