The goal of this repo is to showcase different setups that allow you to share React components between repositories.
graph LR;
1(bare);
2(bare-ts);
3(bare-ts-tooling);
4(css);
5(tailwind);
6(mui);
7(icons);
1-->2;
2-->3;
3-->4;
4-->5;
3-->6;
3-->7;
In most corporate landscapes you have multiple UIs that live in different repositories. In order to avoid duplication and improve consistency, common components such as buttons and dialogs should be shared across those repositories.
Currently the following setups are available - each one builds up on the previous one.
- bare - absolute bare minimum required to share a React component, doesn't even use JSX.
- bare-ts - simplest TypeScript setup possible.
- bare-ts-tooling - same as
bare-ts
, but with ESLint, tsup and a GitHub Action. - css - adds global and component styling to
bare-ts-tooling
. - tailwind - adds TailwindCSS to
css
. - mui - based on
bare-ts-tooling
, custom Material UI theme and custom component. - icons - based on
bare-ts-tooling
, icon library that directly converts SVG files.
In order to not over-complicate things unnecessarily, each sharing setup will have one library (that houses the components to be shared) and one simple Next.js app (that consumes the shared components).
We will also set up all libraries as ES Modules. All libraries (with exception of the bare-bones example) will be written in TypeScript. We are going to use pnpm
as a package manager (but everything will work with npm
or yarn
just fine).
To run a specific library locally,
- clone the repo,
pnpm install
at the root,pnpm build
in the library of your choice,pnpm dev
in its consuming application.
All libraries are intended to be used in React applications that have some kind of proper build step. That means that you will not be able to do old-school shenanigans like sourcing it directly via a script
tag in some hand-written HTML.
This makes our lives as library authors way more comfortable:
- We do not minify our library. The application that consumes the library does.
- We do not bundle dependencies (with exceptions, as always). The application's bundling step resolves dependencies transitively.
- We do not polyfill for random old browsers. The application's bundling step does so if necessary.
Note
Summary: Absolute bare minimum necessary to share a React component.
While I would not recommend doing this, you can use React completely without a build step. Doing so results in the absolute most minimal setup possible.
Create a package.json
First, create a package.json
file:
{
"name": "@ccl/lib-bare",
"main": "./index.js",
"type": "module"
}
name
(Reference): This is the name of your library that users will use to import it. Here, one would import a component like this:import { Button } from '@ccl/lib-bare
.main
(Reference): All things exported from the file referenced here will be available to import. Here,index.js
needs to containexport const Button = ...
so that we can doimport { Button } from '@ccl/lib-bare
.type
(Reference): This tells the importing application what type of JavaScript module to expect. We want to build our libraries as ES Modules, so we set it to"module"
.
Add react
As we're not going to have a build step for this library, properly setting up React as a dependency doesn't really matter and will be covered in a later setup. For now, we'll just do pnpm add react
.
Create a component
Normally, you would create a React component like this:
export const Button = () => (
<button style={{ backgroundColor: 'steelblue' }}>
{children}
</button>
)
For this to work we would need a build step - JSX syntax is not vanilla JavaScript. Instead, our component will look like this:
import { createElement } from 'react'
export const Button = ({ children }) => createElement('button', {
style: { backgroundColor: 'steelblue' }
}, children)
For more information on the createElement
method feel free to head over to the React docs.
Publish it
This repo uses a PNPM workspace setup, so we don't need to publish the packages here. Outside of a monorepo, you would use npm publish or some wrapper around it (like np) for this.
Consume it
Within an app, you can now use the button component by importing it like this:
import { Button } from 'our-library'
Note
Summary: Bare minimum required for sharing a React component written in TypeScript.
Nobody wants to write React without JSX. As we're going to need a build step anyway and there's no sane reason to build something without TypeScript nowadays, we're going to go directly to JSX + TypeScript.
Starting from the Level 1 code, we
- move the
index.js
tosrc/index.tsx
(to better separate code and build artefact later), and - add
typescript
and React's types to ourdevDependencies
(see the Appendix for an overview over the different dependency types):pnpm add -D typescript @types/react
.
Once we set up a build step, the built library will be exposed in dist/index.js
together with a declaration file at dist/index.d.ts
, so we update the package.json
accordingly:
{
"main": "dist/index.js",
"types": "dist/index.d.ts"
}
Setting up TypeScript
In order to compile TypeScript + JSX to vanilla JavaScript, we will add a build
script to our package.json
that runs the TypeScript compiler (we will use fancier tooling like esbuild
in the future):
{
"scripts": {
"build": "tsc"
}
}
By default, the TypeScript compiler doesn't really know what to do with your stuff, so you need to create a tsconfig.json
:
{
"include": ["src"],
"compilerOptions": {
"target": "ESNext",
"moduleResolution": "nodenext",
"outDir": "dist",
"jsx": "react-jsx"
}
}
include
(Reference): This tells TypeScript which files to look at.compilerOptions.target
(Reference): This tells TypeScript which version of ECMAScript (aka which JavaScript standard) to compile to. When developing apps, this should be set to something sensible likeES6
. For libraries like here, we want the consuming application to have full control over its own bundling and polyfilling, so we use the most up-to-date standard, which is exposed asESNext
.compilerOptions.moduleResolution
(Reference): There's three choices here,classic
,node
andnodenext
(akanode16
). You probably never want to useclassic
in modern projects.node
references Node's CommonJS resolution algorithm. Since we want to emit an ES Module, we will usenodenext
, Node's ES Modules resolution algorithm.compilerOptions.outDir
(Reference): This tells TypeScript where to put the compiled files. We want them indist
(don't forget to add that directory to your.gitignore
!)compilerOptions.jsx
(Reference): This informs the TypeScript compiler that we will use JSX syntax. TypeScript can convert that either tocreateElement
calls throughreact
(which we used in Level 1) or newer_jsx
calls available since React 17 throughreact-jsx
(see this blog post). We want the modern stuff and will usereact-jsx
.
The library can now be published and consumed analogous to the library from Level 1. However, VSCode won't be happy with us, as we did not expose any declaration file (the file telling TypeScript which vanilla JS thing has which type, see reference). In order to do that, we extend our tsconfig.json
:
{
"compilerOptions": {
"declaration": true
}
}
Once we run pnpm build
for our library once more (and publish it if necessary), VSCode understands the type of our imported button:
Note
Summary: TypeScript, ESLint + Prettier, tsup, GitHub Actions
This level extends bare-ts
by adding a proper ESLint setup, tsup for faster builds, and a simple GitHub Action.
ESLint
We will now extend Level 2 by setting up linting using ESLint:
pnpm add -D eslint eslint-config-standard-with-typescript eslint-plugin-prettier eslint-config-prettier eslint-config-standard eslint-plugin-react
eslint
: the binaries doing the actual linting.eslint-config-standard-with-typescript
: an opinionated set of rules to follow the JavaScript Standard Style, including TypeScript support.eslint-plugin-react
: react-specific ESLint rules.eslint-plugin-prettier
: let's us run Prettier as part of ESLint. Prettier takes care of things like line lengths etc.eslint-config-prettier
: in order to avoid clashes between ESLint end Prettier, this config overwrites all ESLint rules that would clash with prettier.
Now, we can set up an .eslintrc.js
:
module.exports = {
root: true,
ignorePatterns: ['dist/**/*'],
extends: [
'standard-with-typescript',
'plugin:react/recommended',
'plugin:react/jsx-runtime',
'plugin:prettier/recommended'
],
plugins: ['react', 'prettier'],
rules: {
'react/prop-types': 'off',
'import/order': 'error',
'no-use-before-define': 'off',
'@typescript-eslint/no-use-before-define': 'error',
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
'prettier/prettier': [
'error',
{
tabWidth: 2,
printWidth: 120,
singleQuote: true,
trailingComma: 'none',
semi: false
}
]
},
settings: {
react: {
version: 'detect'
}
},
parserOptions: {
project: 'tsconfig.json'
}
}
A couple of notes on the config:
- We don't want to lint the build artifacts, so we add
dist
to theignorePatterns
. - In addition to
react/recommended
, we also includereact/jsx-runtime
, as we're using the new JSX runtime as of React 17. react/prop-types
is turned off as the props are typed through TypeScript.no-use-before-define
: we want to use the TypeScript version, so we turn off the JavaScript one.
Note
Yes, configuring ESLint is currently quite uncomfortable. There will be a new config format soon, but as of now it's still experimental and many libraries (like
@typescript-eslint
) don't support it, yet.
tsup
Compiling our 5 LOC, 1-component component library currently takes 2.4s on my machine. Once a library gets bigger, the compile time can grow significantly. Over the last years, a lot of fantastic Rust- and Go-based tooling has been developed. We're going to use tsup, which internally uses esbuild:
pnpm add -D tsup
We'll use the following tsup.config.ts
config file:
import { defineConfig } from 'tsup'
export default defineConfig({
entry: ['src/index.tsx'],
dts: true,
target: 'esnext',
format: 'esm',
sourcemap: true,
minify: false
})
Update the package.json
accordingly:
{
"scripts": {
"build": "tsup"
}
}
Running pnpm build
now takes 1.4s on my machine, and only 4ms of those are spent on actually compiling the library.
Watch mode
To avoid having to re-build the library everytime you change something, you can use the watch mode provided by tsup
. Simply add a script to your package.json
:
{
"scripts": {
"dev": "tsup --watch"
}
}
Running pnpm dev
will now re-build the library on file changes.
GitHub Action
Note
As this repository is a monorepo, all actions will be in
.github/workflows
. If you copy one library from here, don't forget to also copy the respective workflow!
We'll add a simple GitHub Actions job that lints the library and builds it:
name: bare-ts-tooling
on: [push]
jobs:
Simple-Gate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: pnpm/action-setup@v2
with:
version: 8.1.0
- uses: actions/setup-node@v3
with:
node-version: "18"
cache: "pnpm"
- run: pnpm install
- name: Linting
run: pnpm lint
- name: Build
run: pnpm build
While there's lots of stuff that could be added in terms of tooling (size checks, auto-publish on tagging,...) this should suffice for now.
Note
Summary: TypeScript, ESLint + Prettier, tsup, GitHub Actions, vanilla CSS
As we're now using tsup
for building our library, adding CSS becomes very comfortable, as tsup
supports this natively (through esbuild
).
Rearranging
But first, we're going to do some quality-of-life improvements by preparing a separation of components:
- add a new file,
src/button/index.tsx
and move the button component there. - replace
src/index.tsx
with justexport * from './button/index.tsx
.
We're using nodenext
for our module resolution in tsconfig.json
, which requires us to use file extensions for our imports. But TypeScript doesn't like this by default - we have to set allowImportingTsExtensions
in our tsconfig
first do to that, which also requires us to set noEmit
.
Note
You could also set your module resolution to
node
and doexport * from './button
instead. We we'd usetsc
to compile our library, we would have to doexport * from './button/index.js
and therefore reference a non-existent file. There's a long explanation by the TS team as to why this is a sensible decision. We're usingtsup
to build our library, so settingnoEmit
is not a problem.
Adding CSS
Create a src/button/styles.css
that includes something like this:
.button {
background: steelblue;
color: white;
border: none;
}
We can then import those styles into our component by doing import './styles.css'
:
import { type PropsWithChildren } from 'react'
import './styles.css'
export const Button: React.FC<PropsWithChildren> = ({ children }) => <button className="button">{children}</button>
If we now run pnpm build
, you will see an index.css
in your dist
folder.
If you want, you can additionally add a global stylesheet by creating src/styles.css
and referencing it in src/index.tsx
:
import './styles.css'
export * from './button/index.tsx'
Note
To avoid clashes between the library and application CSS, I would recommend to not style tags directly and use CSS classes instead - ideally prefixing them in some way, e.g.
.acme-ui-button
.
Using the generated CSS
When consuming your library, you also need to import the generated stylesheet from dist/index.css
. In Next.js, you would typically do this in _app.tsx
.
import '@/styles/globals.css'
import 'our-library/dist/index.css'
/* ... */
Note
The order of CSS imports matters. What the "correct" order is depends a bit on your setup - usually, your application will do a CSS reset, therefore importing your library's CSS after your application's CSS might be sensible.
To make importing the styles a bit nicer, we can replace the main
field with an exports
field to our library's package.json
file:
{
"exports": {
".": "./dist/index.js",
"./styles": "./dist/index.css"
},
}
Afterwards, we can import the CSS like this:
import '@/styles/globals.css'
import 'our-library/styles'
/* ... */
Note
Summary: TypeScript, ESLint + Prettier, tsup, GitHub Actions, Tailwind
Before we start, our goals here are:
- allow Tailwind classes within JSX (
className="bg-indigo-400"
) - allow Tailwind classes via PostCSS syntax in a global stylesheet (
@apply bg-indigo-400
in./src/styles.css
) - allow Tailwind classes via PostCSS syntax in per-component stylesheets (e.g.
./src/button/styles.css
) - expose the library's Tailwind config as a preset for downstream applications (as applications should match the theme of the component library)
Setting up Tailwind
We do the usual commands to set up Tailwind:
pnpm add -D postcss tailwindcss autoprefixer
npx tailwindcss init
This creates a postcss.config.js
and a tailwind.config.js
. As our library is an ES module, we'll have to rename the PostCSS config to postcss.config.cjs
(that's likely a bug in tsup).
As we want to expose custom theme values later, we will split the tailwind.config.js
into two parts:
tailwind.base.ts
includes everything we want to share with downstream applications, like custom colors.tailwind.config.ts
is the Tailwind config used by our library. It extends the base config with configuration specific to our library, likecontent
.
Our tailwind.base.ts
looks like this (yes, Tailwind now supports ES Module config files):
import { type Config } from 'tailwindcss'
const config: Config = {
content: [],
theme: {
extend: {
colors: {
fancy: 'steelblue'
}
}
}
}
export default config
Our tailwind.config.ts
looks like this:
import { type Config } from 'tailwindcss'
import base from './src/tailwind.base.ts'
const config: Config = {
presets: [base],
content: ['./src/**/*.tsx'],
corePlugins: {
preflight: false
}
}
export default config
As we want to expose the base config, we'll put it in src/tailwind.base.ts
and export it in src/index.ts
:
// src/index.ts
import './styles.css'
export * from './button/index.tsx'
export { default as tailwindConfig } from './tailwind.base.ts'
Downstream applications will be able to import the config like this:
import { tailwindConfig } from 'our-library'
The rest of the downstream setup is identical to Level 4 - we import the styles by doing import 'our-library/styles'
in _app.tsx
.
Writing styles
Now that everything is cabled together, esbuild
should correctly invoke PostCSS and extract all styles. In order to test that, we try out all possible variations:
- Inline classes
// src/button/index.tsx
import { type PropsWithChildren } from 'react'
import './styles.css'
export const Button: React.FC<PropsWithChildren> = ({ children }) => (
<button className="button bg-indigo-400/50 hover:bg-indigo-400/60 text-indigo-900 font-medium transition hover:shadow">
{children}
</button>
)
- In the component's stylesheet
/* src/button/style.css */
.button {
@apply rounded px-2 py-1;
}
- In the root stylesheet
/* src/style.css */
@tailwind components;
@tailwind utilities;
button {
@apply font-sans;
}
Note
The first two lines in
src/style.css
are required, otherwise PostCSS doesn't know what to do with inline Tailwind classes.You might notice that
@tailwind base;
is missing - this is intentional, as we don't want any reset styles in our library's CSS. Otherwise, including the library CSS file after an application's CSS will reset the application CSS.
If everything works correctly, running pnpm build
should yield a dist/index.css
that contains classes from src/style.css
(including the inline classes from src/button/index.tsx
) and src/button/styles.css
.
Note the steelblue text at the left bottom, using text-fancy
Note
Summary: TypeScript, ESLint + Prettier, tsup, Material UI
Compared to Tailwind, sharing Material UI (MUI) components is relatively straight-forward.
We're going to start with bare-ts-tooling
, reusing the tsup
setup.
Setting up Material UI
We will make the assumption that all applications using our MUI components will also use MUI. Therefore, we will set up MUI as a peer dependency of our library. The only difference between declaring it as a peer dependency instead of a dependency is, that the downstream application will be forced to use a compatible version of MUI. Imagine having MUI v4 in the application and MUI v5 in the library - that will cause two competing versions of MUI to be in the final application bundle.
pnpm add -D @mui/material --save-peer
For our application, we're going to use next-ts
example provided by MUI.
Theming
MUI provides a plethora of components - it should be obvious that wrapping every single one of them does not make any sense and is certainly not the intention of the MUI authors.
Instead, MUI provides a theming solution - by wrapping your application in a theme, you can customize all design aspects of your application. Think of it as a configuration object shared across all MUI components you use.
We will create a simple theme in src/theme.ts
:
import { createTheme } from '@mui/material'
export const theme = createTheme({
palette: {
primary: {
main: '#ffe4e1'
},
secondary: {
main: '#edf2ff'
}
}
})
Additionally, we will replace the button in src/index.tsx
with src/Button.tsx
(see code) and adapt src/index.tsx
accordingly:
export * from './theme.ts'
export * from './Button.tsx'
And that's it! pnpm build
should correctly generate the contents of the dist
folder.
Consuming
We can use the library theme in our applications like this:
import { theme } from 'our-library';
// Create a theme instance.
export const appTheme = createTheme(theme, {
palette: {
error: {
main: red.A400,
},
},
});
All that's left to do is to use some component, like the fancy button in our case, and consume the theme where necessary:
Note
Summary: TypeScript, tsup, svgr
In this level, we want to export our existing SVG icons as an icon library. We will be using bare-ts-tooling
as a base layer, but can remove everything related to ESLint, as we are only going to deal with SVG source files.
There are different approaches to bundling icons as a library - for example, you can manually create a bundle directly from SVG files by writing a custom parser, or you could directly bundle the SVG files and embed them properly at runtime.
We want to expose clean ES Modules that are nicely tree-shakable, so we are going to convert the SVG icons to React components.
As will all other libraries, we want to expose an ES Module with TypeScript declarations alongside it. If we would be creating a vanilla JS library, we could directly convert the SVGs into JS without any JS-TS transpiling.
We are going to use svgr
to convert SVG to TSX:
- Install the SVGR cli:
pnpm add -D @svgr/cli
- Place all your SVGs in one folder, e.g.
src/icons
. - Create a script in your
package.json
that usessvgr
to convert the icons to TSX:({ "scripts": { "svgr": "svgr --icon --ref --typescript --out-dir tsx src/icons" } }
--icon
is needed to thatsvgr
keeps viewboxes, and--ref
addsforwardRef
statements) - Adjust the
tsup.config.ts
to ingest thesvgr
output:import { defineConfig } from 'tsup' export default defineConfig({ entry: ['tsx/index.ts'], target: 'esnext', format: 'esm', dts: true, sourcemap: true, minify: false })
- In
tsconfig.json
, changemoduleResolution
tonode
, assvgr
doesn't use file extensions in the generated TSX. - Add
tsx
(thesvgr
output directory) to your.gitignore
. - Add a
clean
script to yourpackage.json
:{ "scripts": { "clean": "rm -rf tsx && rm -rf dist" } }
- Cable all scripts together into a
build
script:{ "scripts": { "build": "pnpm clean && pnpm svgr && tsup" } }
Running pnpm build
should now
- remove old build files,
- generate TSX based on the SVG files in
src/icons
, and - convert the generated TSX into a nice bundle.
That's it!
There are three types of dependencies - normal dependencies, development dependencies and peer dependencies. Often it doesn't make that much of a difference what you put where. Also, there are quite a few differences in dependency management between developing an application and developing a library. Here's my mental model:
Application | Library | |
---|---|---|
dependencies |
Dependencies that are referenced within code that will be included in the bundle (e.g. component libraries, react-query ) |
=, with the exceptions (see below) |
devDependencies |
Dependencies needed to build the bundle (e.g. types, build tooling) | =, plus dependencies you want to be bundled in your library bundle (ideally none) |
peerDependencies |
None | Dependencies without which your library is useless within the application context (usually this is only react ). Make sure to make the version requirement in the peer dependencies as loose as possible to maximize compatibility. |