Skip to content

jens-ox/cll

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

45 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Component Library Library

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;

Motivation

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.

Available library setups

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.

Setup

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,

  1. clone the repo,
  2. pnpm install at the root,
  3. pnpm build in the library of your choice,
  4. pnpm dev in its consuming application.

Core Concepts

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 contain export const Button = ... so that we can do import { 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'

Button inside our demo app

Our ugly button within our demo app!

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 to src/index.tsx (to better separate code and build artefact later), and
  • add typescript and React's types to our devDependencies (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 like ES6. 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 as ESNext.
  • compilerOptions.moduleResolution (Reference): There's three choices here, classic, node and nodenext (aka node16). You probably never want to use classic in modern projects. node references Node's CommonJS resolution algorithm. Since we want to emit an ES Module, we will use nodenext, Node's ES Modules resolution algorithm.
  • compilerOptions.outDir (Reference): This tells TypeScript where to put the compiled files. We want them in dist (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 to createElement calls through react (which we used in Level 1) or newer _jsx calls available since React 17 through react-jsx (see this blog post). We want the modern stuff and will use react-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:

VSCode understands the button's type

Level 3: Bare-bones TypeScript with tooling

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 the ignorePatterns.
  • In addition to react/recommended, we also include react/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.

Level 4: CSS

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 just export * 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 do export * from './button instead. We we'd use tsc to compile our library, we would have to do export * 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 using tsup to build our library, so setting noEmit 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'

/* ... */

Level 5: Tailwind

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, like content.

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.

Custom Tailwind button

Note the steelblue text at the left bottom, using text-fancy ☺️

Level 6: Material UI

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:

MUI

Level 7: Icon Library

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.

Converting SVG to TSX

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:

  1. Install the SVGR cli: pnpm add -D @svgr/cli
  2. Place all your SVGs in one folder, e.g. src/icons.
  3. Create a script in your package.json that uses svgr to convert the icons to TSX:
    {
      "scripts": {
        "svgr": "svgr --icon --ref --typescript --out-dir tsx src/icons"
      }
    }
    (--icon is needed to that svgr keeps viewboxes, and --ref adds forwardRef statements)
  4. Adjust the tsup.config.ts to ingest the svgr output:
    import { defineConfig } from 'tsup'
    
    export default defineConfig({
      entry: ['tsx/index.ts'],
      target: 'esnext',
      format: 'esm',
      dts: true,
      sourcemap: true,
      minify: false
    })
  5. In tsconfig.json, change moduleResolution to node, as svgr doesn't use file extensions in the generated TSX.
  6. Add tsx (the svgr output directory) to your .gitignore.
  7. Add a clean script to your package.json:
    {
      "scripts": {
        "clean": "rm -rf tsx && rm -rf dist"
      }
    }
  8. 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!

Appendix

Dependency Types

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.