Support Ukraine 🇺🇦Help Ukrainian ArmyHumanitarian Assistance to Ukrainians

How to create a local React + TypeScript library that I can use across my React projects?

yarik

May 12 2020 at 21:38 GMT

I want to create a local React + TypeScript library and be able to use it across my React projects by installing it like I would install any other npm package.

By local library I mean that I should not need to publish it on npm or anywhere else in order to use it in my projects.

At the same time, I would like to have a great experience developing the library with live reload.

I know that having a local React library is challenging because if I try to add it to one of my projects by using something like npm link or yarn link there will be two copies of React and so the Invalid hooks call error will pop up.

Can someone show how to set this up properly and avoid running into issues?

1 Answer

yarik

May 12 2020 at 21:45 GMT

To explain how to do this, I'll walk through a complete example of a local library.

Let's say we want to create a UI components library for React called example-ui-library.

Initialize the project

We start by initializing the project.

mkdir example-ui-library
cd example-ui-library
yarn init

You should now see a package.json file in the project's directory.

Add TypeScript

Let's install typescript as a development dependency.

yarn add --dev typescript

And add a tsconfig.json file that looks like this (fell free to change the configuration to fit your needs):

{
  "compilerOptions": {
    "baseUrl": "./",
    "module": "esnext",
    "target": "es5",
    "lib": ["es6", "dom", "es2016", "es2017"],
    "sourceMap": true,
    "allowJs": false,
    "declaration": true,
    "moduleResolution": "node",
    "forceConsistentCasingInFileNames": true,
    "noImplicitReturns": true,
    "noImplicitThis": true,
    "noImplicitAny": true,
    "strict": true,
    "suppressImplicitAnyIndexErrors": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "esModuleInterop": true,
    "downlevelIteration": true,
    "jsx": "react"
  },
  "include": ["src"],
  "exclude": ["node_modules", "build"]
}

The important part here is that we tell the TypeScript compiler via the declaration option to emit declaration files (i.e., files ending with .d.ts that will allow the users of our library to have types for our library).

Add React

Next, let's add React to our library.

Since this is a library and not an app, we want to have React as a peer dependency rather than a direct dependency so that we rely on the users of the library to already have React as dependency rather than bundling a copy of React ourselves.

So, let's first add react as a peer dependency:

yarn add --peer react

Then, because we want to be able to test our library in development, we should also add React as a development dependency:

yarn add --dev react

Let's also not forget to add types for React.

yarn add --dev @types/react

Add a UI component

Let's actually add a React component to our UI library.

First, let's create the src directory.

mkdir src

And add a Button component:

// src/Button.tsx
import React, { ButtonHTMLAttributes } from 'react'

interface Props extends ButtonHTMLAttributes<HTMLButtonElement> {
  bgColor?: string
  textColor?: string
}

export const Button: React.FC<Props> = ({
  bgColor = 'yellow',
  textColor = 'black',
  children,
  ...rest
}) => {
  return (
    <button style={{ backgroundColor: bgColor, color: textColor }} {...rest}>
      {children}
    </button>
  )
}

Let's also add an entry point file that exports all our components.

// src/index.ts
export { Button } from './Button'

Add Rollup

The next step would be to add a bundler such as webpack, Rollup, or Parcel to bundle our library code so it can be consumed by the users of our library.

In this case, we will be using Rollup as it's generally the preferred solution when it comes to libraries because it creates leaner and faster bundles.

We want Rollup to create at least two versions of our library's bundle:

  • A CommonJS version (one in which require() is used) that will consumed by NodeJS applications.
  • An ES module version (one in which import is used) that will be consumed by bundlers, such as webpack or Parcel, and will allow them to do optimizations like code-splitting.

We will name the CommonJS bundle as index.js and the ES module bundle as index.es.js.

We also need to specify them in our package.json file under the main and module keys:

{
  "name": "example-ui-library",
  "version": "1.0.0",
  "main": "build/index.js",
  "module": "build/index.es.js",
  "author": "example",
  "license": "UNLICENSED",
  "private": true,
  "devDependencies": {
    "@types/react": "^16.9.34",
    "react": "^16.13.1",
    "typescript": "^3.8.3"
  },
  "peerDependencies": {
    "react": "^16.13.1"
  }
}

Let's now install Rollup along with the Rollup plugins that we will be using (all as dev dependencies).

yarn add --dev rollup @rollup/plugin-commonjs @rollup/plugin-node-resolve rollup-plugin-peer-deps-external rollup-plugin-typescript2

Here's a brief explanation of the Rollup plugins we installed:

  • @rollup/plugin-commonjs converts CommonJS modules to ES6 modules. This will be used for our dependencies inside node_modules that provide only the CommonJS format.
  • @rollup/plugin-node-resolve allows resolving paths of imported modules using the Node resolution algorithm.
  • rollup-plugin-peer-deps-external will exclude our peer dependencies (such as React) from the bundle.
  • rollup-plugin-typescript2 passes .ts and .tsx files through the TypeScript compiler using the options we specified in tsconfig.json.

Let's now create the configuration file for Rollup:

// rollup.config.js
import resolve from '@rollup/plugin-node-resolve'
import external from 'rollup-plugin-peer-deps-external'
import commonjs from '@rollup/plugin-commonjs'
import typescript from 'rollup-plugin-typescript2'
import packageJson from './package.json'

export default {
  input: 'src/index.ts',
  output: [
    {
      format: 'cjs',
      file: packageJson.main,
      exports: 'named',
      sourcemap: true
    },
    {
      format: 'es',
      file: packageJson.module,
      exports: 'named',
      sourcemap: true
    }
  ],
  plugins: [
    resolve(),
    external(),
    commonjs({
      include: ['node_modules/**'],
    }),
    typescript({
      clean: true,
      rollupCommonJSResolveHack: true,
      exclude: ['node_modules'],
    }),
  ]
}

Next, let's add a couple of scripts to our package.json.

"scripts": {
  "prebuild": "rm -rf ./build",
  "build": "rollup --config"
}
  • The build script runs Rollup and tells it to use the configuration file.
  • The prebuild script removes the ./build directory before every build (so we start fresh every time).

If we now run yarn build, a build directory will be created with the following structure:

build/
  Button.d.ts
  index.d.ts
  index.es.js
  index.es.js.map
  index.js
  index.js.map

As you can see, the output of the build includes the CommonJS bundle, the ES module bundle, and the TypeScript declaration files.

Note: Depending on the dependencies that your library has, you might encounter an error during the build that looks like this:

Error: 'pipe' is not exported by node_modules/lodash/fp.js, imported by src/Button.tsx

In your case, the error might be related to a completely different export of a completely different library, but it will have the same structure as the one above.

This is likely happening because the commonjs Rollup plugin was not able to resolve a named export (in the example error above, it's referring to the pipe export of lodash/fp).

This can be fixed by explicitly specifying the named exports that were not resolved via the namedExports option. So, for the above example that would be:

commonjs({
  include: ['node_modules/**'],
  namedExports: {
    'node_modules/lodash/fp.js': ['pipe'],
  }
})

Using the local library

Now that we built our library, we can add it to the dependencies of a React project like this:

yarn add path/to/example-ui-library

And use it like any other dependency by importing what we need from it. For example:

// some-project/src/App.tsx
import React from 'react'
import { Button } from 'example-ui-library'

export const App: React.FC = () => {
  return (
    <Button bgColor="blue" textColor="yellow">
      Fancy button
    </Button>
  )
}

However, if inside our library we are using React hooks, we will get the following error:

Uncaught Error: Invalid hook call. Hooks can only be called inside of the body of a function component. This could happen for one of the following reasons:
1. You might have mismatching versions of React and the renderer (such as React DOM)
2. You might be breaking the Rules of Hooks
3. You might have more than one copy of React in the same app

The reason in our case is the third one:

3. You might have more than one copy of React in the same app

This happens because when we yarn add our local library, yarn copies the node_modules directory as well, so we get something that looks like this:

some-project/
  node_modules/
    example-ui-library/
      build/
      node_modules/
        react/
      package.json
      ...
    react/

So, the require('react') in our library's bundle resolves to the copy of React that is inside the copied node_modules directory of example-ui-library (we have react there because it's a dev dependency) rather than the one inside the node_modules directory of some-project.

As a consequence, we end up using two copies of React in our project: the example-ui-library's one and the some-project's one.

To solve this issue, we can either prevent the node_modules directory from being copied during the installation of the library or just delete the copied node_modules right after the installation.

There doesn't seem to be a way to prevent node_modules from being copied, so we will go with the second option.

Turns out that we can delete the node_modules directory right after our library installs by using a postinstall script that we specify in the scripts of our library's package.json:

"scripts": {
  ...,
  "postinstall": "rm -rf node_modules"
},

When we now install our library again, we should no longer have the invalid hooks call error from before.

Set up the dev environment with live reload

Now that we have a local React + TypeScript library that we can use in our projects, let's see what we need to do to have a nice experience developing it.

We essentially want to have a playground in which we use our library and have it live reload as we are doing changes.

We will create such playground by using Parcel, which is a bundler that works out of the box without needing any configuration.

yarn add --dev parcel-bundler

Since we want to render the components that are part of our library, we will need ReactDOM, so let's add it as a dev dependency:

yarn add --dev react-dom @types/react-dom

Next, let's create a playground directory:

mkdir src/playground

And add an index.tsx file in which we use our button:

// src/playground/index.tsx
import React from 'react'
import ReactDOM from 'react-dom'
import { Button } from '../Button'

ReactDOM.render(
  <Button bgColor="green" textColor="white">Submit</Button>,
  document.getElementById('root'),
)

Let's also add an index.html file inside the playground directory, which is referencing the above index.tsx file:

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>Playground</title>
  </head>

  <body>
    <div id="root"></div>

    <script src="./index.tsx"></script>
  </body>
</html>

Next, let's add the start script that will start the playground using Parcel:

"scripts": {
  "start": "parcel --out-dir playground-build ./src/playground/index.html",
  ...
},

You can now open localhost:1234 to see the button, then if you make any changes to the implementation of the button, the page will automatically reload with the latest changes!

Note that we told Parcel to place the generated files inside the playground-build directory, so we want to add this directory to .gitignore as well as to the exclude array in tsconfig.json, just like we do for the build directory.

Update to the latest version of the library

As we develop and improve our library, we may want to update it inside projects that are using it.

To do that, we should first build the latest version of the library:

yarn build

Then, we just re-add the library in the projects in which we want to update it:

yarn add path/to/example-ui-library

And that's it!

Conclusion

We've seen how to create a local React + TypeScript library and be able to install it inside a project just like any other npm package.

At the same time, we've seen how to set up a playground environment that let's us develop the library with live reload.

If you want to create a local React library, chances are that you will need a more complex Rollup configuration than the basic one we've seen in the example.

For instance, if in your library you want to import SVG icons as React components, you will need the @svgr/rollup plugin for Rollup, and the equivalent @svgr/parcel-plugin-svgr for Parcel.

It's important that you remember that Rollup is used to create the library bundles, while Parcel is used to create the development environment for you to work on your library. Therefore, if you're adding a Rollup plugin, you'll probably need to add an equivalent Parcel plugin.

In your library, you will probably also want to add linting and tests, and create an optimized bundle for production.

So, as you can see, we've only covered the basics for setting up a local library. There's still a lot for you to explore!

claritician © 2022