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?
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
.
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.
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).
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
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'
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:
require()
is used) that will consumed by NodeJS applications.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"
}
build
script runs Rollup and tells it to use the configuration file.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'],
}
})
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.
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.
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!
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!