DEV Community

Cover image for Code splitting React components with TypeScript and NO Babel
Brian Neville-O'Neill
Brian Neville-O'Neill

Posted on • Originally published at blog.logrocket.com on

Code splitting React components with TypeScript and NO Babel

Written by Paul Cowan✏️

The secret to web performance is less code

With the advent of the now-infamous single page application, extreme amounts of JavaScript started getting pushed to the browser. The sheer weight of JavaScript is one problem, but the browser also has to parse the downloaded JavaScript. The UI thread of the browser can hang under such duress as it is pushed beyond its intended purpose. The obvious answer is to ship less code. Code splitting allows us to do that without shipping fewer features.

Code splitting is a complicated business where a bundle of code is split into smaller chunks that can be loaded on demand. Thankfully tools like webpack abstract this complexity behind a less complicated API. Unfortunately, this less complicated API is still very complex. In the React ecosystem, tools like loadable-componets add a much simpler veneer of sanity around dynamic imports.

LogRocket Free Trial Banner

Code splitting by route

I want to see more rendering control returned to the server. The browser is not meant to render HTML, and there are many good reasons why rendering React server-side is preferable. I am predicting that we will see a return to more HTML rendered server-side.

Below is some code from my company website that uses dynamic imports to create smaller code files that get loaded on demand.

import React from 'react';
import loadable from '@loadable/component';
import * as Urls from '../urls';
import { RouteProps, Route, Switch } from 'react-router';

export type Page<P = unknown> = RouteProps & {
  heading: string;
  path: string;
  footerPage?: boolean;
} & P;

const fallback = <div>loading....</div>;

const Home = loadable(() => import('src/components/Home'), {
  fallback,
});
const OSS = loadable(() => import('src/components/OSS'), {
  fallback: <div>Loading...</div>,
});
const Blog = loadable(() => import('src/components/Blog'), {
  fallback: <div>Loading...</div>,
});

export const routable: Page[] = [
  {
    heading: 'Home',
    path: Urls.Home,
    component: Home,
    exact: true,
  },
  {
    heading: 'OSS',
    path: Urls.OSS,
    component: OSS,
    exact: true,
  },
// etc.
Enter fullscreen mode Exit fullscreen mode

The loadable function takes a dynamic import as an argument and will do the hard work for us. Running a webpack build creates several smaller files that can be lazy-loaded:

list of files that have been code splitted

@loadable/babel-plugin

I am a big TypeScript fan, and I have always stayed away from anything requiring Babel as having to maintain two different transpiler configurations is not a road I am willing to travel.

The @loadable/babel-plugin transforms code like this:

import loadable from '@loadable/component';

export const LazyFoo = loadable(() => import('./input/AsyncDefaultComponent'));
Enter fullscreen mode Exit fullscreen mode

into code like this:

import loadable from 'loadable-components';

export const LazyFoo = loadable({
  chunkName() {
    return 'input-AsyncDefaultComponent';
  },
  isReady(props) {
    return (
      typeof __webpack_modules__ !== 'undefined' &&
      Boolean(__webpack_modules__[this.resolve(props)])
    );
  },
  requireAsync: () =>
    import(
      /* "webpackChunkName":"input-AsyncDefaultComponent" */ './input/AsyncDefaultComponent'
    ),
  requireSync(props) {
    return typeof '__webpack_require__' !== 'undefined'
      ? __webpack_require__(this.resolve(props))
      : eval('module.require')(this.resolve(props));
  },
  resolve() {
    if (require.resolveWeak)
      return require.resolveWeak(
        /* "webpackChunkName":"input-AsyncDefaultComponent" */ './input/AsyncDefaultComponent',
      );
    else
      return eval('require.resolve')(
        /* "webpackChunkName":"input-AsyncDefaultComponent" */ './input/AsyncDefaultComponent',
      );
  },
});
Enter fullscreen mode Exit fullscreen mode

loadable-ts-transformer

Now enters the hero of the piece, namely the loadable-ts-transformer which does the same job as its Babel counterpart only it does this by creating a TypeScript transformer. A TypeScript transformer allows us to hook into the compilation pipeline and transform code just like is listed above with the Babel plugin. A full AST is at the developer’s disposal to bend to their will.

Hooking up the loadable-ts-transformer to a webpack build

The first step is to define the components that we want to split into smaller chunks with the loadable-component’s loadable function:

const Home = loadable(() => import('src/components/Home'), {
  fallback,
});
Enter fullscreen mode Exit fullscreen mode

Next, webpack needs configured. Typically in a webpack ssr (server-side rendered) build, you have a server webpack configuration file and a client webpack configuration file.

The webpack server configuration takes care of bundling the node express code that renders the react components server-side.

To keep duplication down between the two configuration files, I use webpack-merge to create a common.config.js file that is merged into both the client.config.js and server.config.js files.

Below is an example of a common.config.js file that has the common components for both the webpack client and server configuration files:

const path = require("path");
const { loadableTransformer } = require('loadable-ts-transformer');

module.exports = {
  resolve: {
    extensions: ['.ts', '.tsx', '.js'],
  },
  module: {
    rules: [
      {
        test: /\.tsx?$/,
        exclude: /node_modules/,
        loader: 'ts-loader',
        options: {
          transpileOnly: true,
          getCustomTransformers: () => ({ before: [loadableTransformer] }),
        },
      }
    ],
  },
};
Enter fullscreen mode Exit fullscreen mode

I use ts-loader to transpile TypeScript into JavaScript and ts-loader has a getCustomTransformers option which we can use to add the loadable-ts-transformer.

The client.config.js file looks like this:

const path = require("path");
const merge = require('webpack-merge');
const LoadablePlugin = require('@loadable/webpack-plugin');
const commonConfig = require('./webpack.config');
const webpack = require('webpack');

module.exports = () => {
  return merge(commonConfig, {
    output: {
      path: path.resolve(__dirname, 'public'),
      publicPath: '/assets/',
      filename: '[name].[chunkhash].js',
    },
    entry: {
      main: path.resolve(__dirname, 'src/client.tsx'),
    },
    optimization: {
      splitChunks: {
        name: 'vendor',
        chunks: 'initial',
      },
    },
    plugins: [
      new LoadablePlugin(),
      new webpack.DefinePlugin({ __isBrowser__: "true" })
    ],
  });
};
Enter fullscreen mode Exit fullscreen mode

Note the use of the webpack.DefinePlugin to add an __isBrowser__ property into the bundled code. This stops having to use endless typeof window === 'undefined' checks to determine if code is executing on the server or browser.

The client.config.js file also adds the @loadable/webpack-plugin to the plugin array. Do not add this to the server.config.js.

The server.config.js file looks like this:

const path = require("path");
const merge = require('webpack-merge');
const commonConfig = require('./webpack.config');
const webpack = require('webpack');
const nodeExternals = require('webpack-node-externals');

module.exports = () => {
  return merge(commonConfig, {
    target: 'node',
    externals:  nodeExternals({
      whitelist: [
          /^@loadable\/component$/,
          /^react$/,
          /^react-dom$/,
          /^loadable-ts-transformer$/,
        ]
      }),
    ],
    output: {
      path: path.resolve(__dirname, 'dist-server'),
      filename: '[name].js',
    },
    entry: {
      server: path.resolve(__dirname, 'src/server.tsx'),
    },
   plugins: [
     new webpack.DefinePlugin({ __isBrowser__: "false" })
   ]
  });
};
Enter fullscreen mode Exit fullscreen mode

The webpack externals section has tripped me up many times. The externals property allows you to whitelist what gets bundled in a webpack server build. You do not want to be bundling the entirety of the node_modules folder. I find the webpack-node-externals package which has a whitelist option extremely useful.

loadable-components server-side

The server.config.js file defines and entry point of src/server/index.ts which looks like this:

export const app = express();
const rootDir = process.cwd();

const publicDir = path.join(rootDir, isProduction ? 'dist/public' : 'public');
app.use(express.static(publicDir));

app.use(bodyParser.urlencoded({ extended: false }));
app.use(bodyParser.json());

app.get('/*', async (req, res) => {
  await render({
    req,
    res,
  });
});
Enter fullscreen mode Exit fullscreen mode

The important points of the above code are:

  • The app.use(express.static(publicDir)); code points express to the static files that are outputted by webpack using the express static function
  • A catchall app.get('/*. async (req. res) => { route points to a reusable render function that I will explain next

The render function is listed below:

const statsFile = path.resolve(process.cwd(), 'dist/loadable-stats.json');

export async function render({ req, res }: RendererOptions): Promise<void> {
  const extractor = new ChunkExtractor({
    entrypoints: ['client'],
    statsFile,
  });

  const context: StaticRouterContext = {};

  const html = renderToString(
    extractor.collectChunks(
      <StaticRouter location={req.url} context={context}>
        <Routes />
      </StaticRouter>,
    ),
  );

  res.status(HttpStatusCode.Ok).send(`
    <!doctype html>
    <html lang="en">
      <head>
        <meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
        <meta httpEquiv="X-UA-Compatible" content="IE=edge" />
        <meta charSet="utf-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1" />
        ${extractor.getStyleTags()}   
      </head>
      <body>
        <div id="root">${html}</div>
        ${extractor.getScriptTags()}
      </body>
    </html>
`);
}
Enter fullscreen mode Exit fullscreen mode

The code above makes use of the ChunkExtractor component that collects chunks server-side and then creates script tags or script elements that can be used in the outputted HTML.

${extractor.getStyleTags()} will output the CSS link tags and ${extractor.getScriptTags()} will output the JavaScript script tags.

When running your build, the @loadable/webpack-plugin generates a file called loadable-stats.json, which contains information about all the entries and chunks from webpack.

Once that’s in place, ChunkExtractor is responsible for finding your entries from this file.

The entryPoints array of the ChunkExtractor component is set to ['client'] which maps to the client property of the webpack client.config.js file:

entry: {
  client: path.join(process.cwd(), 'src/client.tsx'),
 },
Enter fullscreen mode Exit fullscreen mode

Client rehydration

The client config file’s entry point is now an object with a client property:

entry: {
  client: path.join(process.cwd(), 'src/client.tsx'),
 },
Enter fullscreen mode Exit fullscreen mode

The client.tsx file is listed below:

import React from 'react';
import { hydrate } from 'react-dom';
import { loadableReady } from '@loadable/component';

import { App } from '../containers/App';

const bootstrap = (): void => {
  const root = document.getElementById('root');

  if (!root) {
    return;
  }

  hydrate(<App />, root);
};

loadableReady(() => bootstrap());
Enter fullscreen mode Exit fullscreen mode

Normally when rehydrating React server-side rendered code, you would use ReactDom’s hydrate function but in the loadable-component's world above, the loadable-component’s loadableReady function is used to wait for all the scripts to load asynchronously to ensure optimal performances. All scripts are loaded in parallel, so you have to wait for them to be ready using loadableReady.

Epilogue

I have avoided using many of the code splitting packages because of the need for Babel. Theloadable-ts-transformer has cured this.

If you would like to see this added to the loadable-component’s source then please chime in on this issue where I discovered about its existence.


Full visibility into production React apps

Debugging React applications can be difficult, especially when users experience issues that are difficult to reproduce. If you’re interested in monitoring and tracking Redux state, automatically surfacing JavaScript errors, and tracking slow network requests and component load time, try LogRocket.

Alt Text

LogRocket is like a DVR for web apps, recording literally everything that happens on your React app. Instead of guessing why problems happen, you can aggregate and report on what state your application was in when an issue occurred. LogRocket also monitors your app's performance, reporting with metrics like client CPU load, client memory usage, and more.

The LogRocket Redux middleware package adds an extra layer of visibility into your user sessions. LogRocket logs all actions and state from your Redux stores.

Modernize how you debug your React apps — start monitoring for free.


The post Code splitting React components with TypeScript and NO Babel appeared first on LogRocket Blog.

Top comments (0)