DEV Community

Cover image for Creating a reusable Design System between React and React Native with Tamagui
Alvaro GuimarĂŁes for Devhat

Posted on

Creating a reusable Design System between React and React Native with Tamagui

TLDR;

You can check the repository generated by this article here.

🚀 Introduction

Many times, while developing mobile applications with React Native, I have thought about the possibility of reusing components in both web and mobile contexts. Recently, I came across a library called Tamagui that allows components to be shared in both React Web and React Native.

So I had the challenge of creating a separate component library that could be used in both React Web and React Native.

📦 Starting the project with Vite

Let's start by creating a template with vite. According to the Vite documentation, it is a build tool that aims to provide a faster and leaner development experience for modern web projects.

pnpm create vite@latest
Enter fullscreen mode Exit fullscreen mode
╰─○ pnpm create vite@latest
âś” Project name: library
✔ Select a framework: › React
✔ Select a variant: › TypeScript + SWC
Enter fullscreen mode Exit fullscreen mode

This setup will give us an initial configuration with React and TypeScript geared towards web application development. However, what we really need is a configuration that allows us to develop components to be distributed and used by other applications.

To achieve this, let's adjust our vite.config.ts to configure the development mode for a library.

// /vite.config.ts
import { defineConfig } from "vite";

// https://vitejs.dev/config/
export default defineConfig({
  build: {
    lib: { 
      // defining the entry point of our component library.
      entry: "src/index.ts", 
        // defining the distribution formats of our library (CommonJS and ESM).
        formats: ["cjs", "es"]  
      },
      rollupOptions: {
        // defining the external dependencies of our library.
        // These dependencies will not be included in the final bundle.
      external: ["react", "react/jsx-runtime", "react-dom"], 
    },
  },
});
Enter fullscreen mode Exit fullscreen mode

In the file specified in entry, let's create the entry point of our library.

// /src/index.ts
export * from "./components";
Enter fullscreen mode Exit fullscreen mode

let's create an example component.

// /src/components/Text/Text.tsx

export const Text = () => {
  return <p>Text</p>;
};
Enter fullscreen mode Exit fullscreen mode

our file structure will look like this:

  /src
    /components
      /text
        text.tsx
        index.ts
      index.ts
    index.ts
  vite.config.ts
Enter fullscreen mode Exit fullscreen mode

When the pnpm vite build command is executed, some files will be generated inside the dist folder with the name of your library.

// /dist/library.js

import { jsx as t } from "react/jsx-runtime";
const i = () => /* @__PURE__ */ t("text", { children: "Text!" });
export {
  i as Text
};
Enter fullscreen mode Exit fullscreen mode

Note that there are no external dependencies included in the final bundle because we defined them as external in vite.config.ts. For this reason, we need to specify in the package.json that these dependencies should come from outside (those installing the library should have them as dependencies). To do this, let's move React and ReactDOM to the devDependencies field since we only need these dependencies during development, and add them to the peerDependencies section of our package.json.

  "peerDependencies": {
    "react": "18.2.0",
    "react-dom": "18.2.0"
  },
  "devDependencies": {
    "react": "18.2.0",
    "react-dom": "18.2.0"
    // other dependencies
  }
Enter fullscreen mode Exit fullscreen mode

This article by Naveera explains the difference between each type of dependency.

Let's also update the scripts section of the package.json to include the vite build command:

// /package.json
  "scripts": {
    "build": "vite build"
  },
  "devDependencies": {
    "react": "18.2.0",
    "react-dom": "18.2.0",
    "typescript": "5.4.3",
    "vite": "5.2.3",
    "vite-plugin-dts": "3.7.3"
  },
  "peerDependencies": {
    "react": "18.2.0",
    "react-dom": "18.2.0"
  }
Enter fullscreen mode Exit fullscreen mode

⛓️ Integrating with Typescript

When observing the distribution folder (dist) generated by Vite when running the build command, you may notice that our TypeScript types are not present. This happens because Vite transforms TypeScript into JavaScript to run in the context of browsers, where types are not necessary. However, in our case, as we are building the project to share with other developers, types are essential for bug prevention and improved development experience.

Let's address this issue by installing some new plugins for Vite:

  pnpm add -D vite-plugin-dts
Enter fullscreen mode Exit fullscreen mode
// /vite.config.ts
import { defineConfig } from "vite";
import dtsPlugin from "vite-plugin-dts";
import tsconfigPaths from "vite-tsconfig-paths";

// https://vitejs.dev/config/
export default defineConfig({
  plugins: [
    // The dtsPlugin will generate type declaration files for each file inside the src folder.
    dtsPlugin(),
  ],
  build: {
    lib: { 
      entry: "src/index.ts", 
      // Setting the distribution formats for our library (CommonJS and ESM).
      formats: ["cjs", "es"] 
    },
    rollupOptions: {
      // Setting the external dependencies for our library. 
      // These dependencies will not be included in the final bundle.
      external: ["react", "react/jsx-runtime", "react-dom"],
    },
  },
});
Enter fullscreen mode Exit fullscreen mode

We will also make an adjustment in the tsconfig.json and package.json to configure the generation of sourcemaps from the generated types. This will allow the editor to provide the original source code instead of the compiled code when we search for references to imported libraries.

// /tsconfig.json
    // ...
    "skipLibCheck": true,
    "declaration": true,
    "declarationMap": true,

    /* Bundler mode */
    "moduleResolution": "bundler",
    // ...
Enter fullscreen mode Exit fullscreen mode
// /package.json
  // ...
  "source": "./src/index.ts",
  "files": [
    "dist",
    "src"
  ],
  // ...
Enter fullscreen mode Exit fullscreen mode

♟ Tree Shaking

By default, Vite merges all source files into a single .js file to be served to the browser. You can observe this behavior by looking at the generated files in the distribution folder:

dist/
  components/
    Text/
      text.d.ts
      text.d.ts.map
      index.d.ts
      index.d.ts.map
    index.d.ts
    index.d.ts.map
  index.d.ts
  index.d.ts.map
  library.js
  library.cjs
Enter fullscreen mode Exit fullscreen mode

All JavaScript content has been merged into a single file, while the types remain isolated from each other. However, this behavior may not be ideal in the context of library development. With all source code in a single file, when someone uses a component or function generated by your library, all JavaScript content will be included in the final bundle, even if they are only using a few components. This increases the final size of the application and consequently the weight of your application/website. In this section, we will configure Vite to consider each file in the project as a separate module that imports other modules. This will allow bundlers like Vite and webpack to remove unused components in the final build.

To configure each file as an entry point, we will use the glob library, which allows us to find each file within the src folder using a minimatch expression.

pnpm add -D glob @types/node
Enter fullscreen mode Exit fullscreen mode

The @types/node library will allow us to use internal node libraries with TypeScript, such as node:url and node:path.

Let's update the rollup configuration in vite.config.ts so that it maps all files within the src folder and adds them as entry points in the rollup configuration.

// /vite.config.ts

import { defineConfig } from "vite";
import dtsPlugin from "vite-plugin-dts";
import { dirname, extname, relative, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { glob } from "glob";

// Absolute path of the current directory from the root of the file system.
const __dirname = dirname(fileURLToPath(import.meta.url)); // /home/user/folder/library or C:\Users\user\folder\library

const computeAllSrcFiles = (): Record<string, string> => {
  // Find all .ts and .tsx files within the src folder.
  const files = glob.sync(["src/**/*.{ts,tsx}"]);

  const paths = files.map((file) => [
    // Remove the file extension and calculate the relative path from the src folder.
    /* key: */ relative(
      "src",
      file.slice(0, file.length - extname(file).length)
    ),

    // Convert the file path to an absolute path.
    /* value: */ fileURLToPath(new URL(file, import.meta.url)),
  ]);

  return Object.fromEntries(paths);
  // Convert the array of paths into an object.
};

// https://vitejs.dev/config/
export default defineConfig({
  plugins: [dtsPlugin()],
  build: {
    lib: {
      // Set the entry point of our component library for Vite.
      entry: resolve(__dirname, "src/main.ts"),
      // Set the distribution formats for our library (CommonJS and ESM).
      formats: ["cjs", "es"],
      // Set the output file name.
      // `entryName` is the file name without the extension,
      // and `format` is the distribution format.
      fileName(format, entryName) {
        if (format === "es") return `${entryName}.js`;
        return `${entryName}.${format}`;
      },
    },
    rollupOptions: {
      // Set the external dependencies for our library.
      external: ["react", "react/jsx-runtime", "react-dom"],
      // The `input` configuration in rollupOptions allows you to provide an object
      // specifying output files pointing to source code files.
      input: computeAllSrcFiles(),
    },
  },
});
Enter fullscreen mode Exit fullscreen mode

Now, our distribution folder will contain the following generated files:

/dist
  /components
    Button.cjs
    Button.d.ts
    Button.d.ts.map
    Button.js
    Button.cjs
    index.d.ts
    index.d.ts.map
    index.js
  index.cjs
  index.d.ts
  index.d.ts.map
  index.js
Enter fullscreen mode Exit fullscreen mode

đź’… Setting up Storybook

Storybook is a tool for building components and pages in isolation, which is very useful for testing and documenting interfaces and components. When developing a component library, it is common to want to have a catalog of our components and documentation of their properties and behaviors, streamlining the development and usage process of the components.

We can set up our initial Storybook file with the following command:

# At the time of writing this article, the version of Storybook is 8.0.4
npx storybook@latest init
Enter fullscreen mode Exit fullscreen mode

This will generate a folder called .storybook with files named main.ts and preview.ts, along with a folder of examples called stories.

  .storybook/
    main.ts
    preview.ts
  src/ 
    stories/
Enter fullscreen mode Exit fullscreen mode

The stories folder contains components and examples of how you can use Storybook, so it can be safely removed. Let's delete the stories folder and improve our Text component by including a text.stories.tsx file and a typings file text.types.ts.

// /src/components/text/text.types.ts

import { ComponentPropsWithoutRef, ReactNode } from "react";

export type TextProps = ComponentPropsWithoutRef<"p"> & {
  // The ComponentPropsWithoutRef interface provides the base typing
  // for the <p> element without adding a ref.
  children?: ReactNode;
};
Enter fullscreen mode Exit fullscreen mode
// /src/components/text/text.stories.tsx

import type { Meta, StoryObj } from "@storybook/react";
import { Text } from "./text";

export default {
  component: Text,
  title: "Components/Text",
} satisfies Meta<typeof Text>;

type Story = StoryObj<typeof Text>;

export const StoryDefault: Story = {
  // Here, we are configuring Storybook to render the text component and name it as Default.
  name: "Default",
  render: (props) => <Text {...props} />,
};
Enter fullscreen mode Exit fullscreen mode

If you are unsure about the content within the stories file, the official documentation of Storybook is a good starting point.

// /src/components/text/text.tsx

import { TextProps } from "./text.types";

export const Text = ({ children, ...props }: TextProps) => (
  <p {...props}>{children}</p>
);
Enter fullscreen mode Exit fullscreen mode

All this structuring is optional, and you can organize the files and styles however you like.

If you start Storybook with the command pnpm storybook, you will notice that our text component has already been rendered on the screen.

🧪 Adding Vitest with React Testing Library

vitest is a testing framework similar to jest that integrates well with projects using Vite. It allows us to reuse plugins and configurations already set up in the vite.config.ts, making the test setup process easier.

Since we are testing the front-end, we will also rely on the React Testing Library, which provides support for rendering components and custom queries in the DOM.

Let's start by installing the necessary dependencies and creating a vitest.config.ts file in the project root:

pnpm add -D vitest @testing-library/jest-dom @testing-library/react jsdom
Enter fullscreen mode Exit fullscreen mode
// /vitest.config.ts

import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react-swc";

export default defineConfig({
  plugins: [react()],
  test: {
    environment: "jsdom",
    globals: true,
    include: ["src/**/*.test.ts", "src/**/*.test.tsx"],
    // In this file, we will configure the integration of vitest
    // with the React Testing Library
    setupFiles: ["./src/setup-tests.ts"],
  },
});
Enter fullscreen mode Exit fullscreen mode
// /src/setup-tests.ts
import "@testing-library/jest-dom/vitest";
Enter fullscreen mode Exit fullscreen mode

Some adjustments will also need to be made in the tsconfig.json so that TypeScript recognizes the global variables provided by vitest:

// /tsconfig.json
  "compilerOptions": {
    // add reference to vitest/globals in the types property.
    "types": ["vitest/globals"], 
    // ...
Enter fullscreen mode Exit fullscreen mode

Finally, let's add a command in the scripts section of the package.json to run the tests more easily:

// /package.json
  "scripts": {
    "test": "vitest"
  },
Enter fullscreen mode Exit fullscreen mode

Finally, it's time to create a test for our text component and see the result in the terminal:

// /src/components/text/text.test.tsx

import { render, screen } from "@testing-library/react";

import { Text } from "./text";

describe("[Components]: Text", () => {
  it("renders without crash", () => {
    render(<Text>Hello World</Text>)
    const component = screen.getByText("Hello World");
    expect(component).toBeDefined();
  });
});
Enter fullscreen mode Exit fullscreen mode
 âś“ src/components/text/text.test.tsx (1)
   âś“ [Components]: Text (1)
     âś“ renders without crash

 Test Files  1 passed (1)
      Tests  1 passed (1)
   Start at  15:29:37
   Duration  383ms
Enter fullscreen mode Exit fullscreen mode

đź‘ľ Adding Tamagui

Tamagui is a tool that enables code sharing between different platforms that support React (web, android, ios), making styling in the React environment more pleasant and faster.

⚠️ Before we continue: Limitations regarding PNPM

There are some limitations when using pnpm to configure environments involving React Native, and even Storybook, due to the way the package manager resolves its dependencies. Unfortunately, React Native doesn't work well with the structure created to resolve dependencies using symbolic links. For this reason, we will use a flag in the .npmrc file that tells pnpm to create a file structure similar to npm and yarn in the node_modules folder:

// /.npmrc
# https://pnpm.io/npmrc#node-linker
node-linker=hoisted
Enter fullscreen mode Exit fullscreen mode

It will also be necessary to remove the type: "module" declaration inside the package.json file so that @tamagui can function correctly.

After this configuration, it is necessary to use pnpm install so that it recreates the node_modules with the new structure.

Continuing...

Let's start by installing the necessary dependencies to configure the tamagui environment in our library.

pnpm add expo expo-linear-gradient -D react-native @tamagui/core @tamagui/vite-plugin
Enter fullscreen mode Exit fullscreen mode

For some reason, tamagui has a dependency on expo-linear-gradient internally, so it is necessary to install both expo and expo-linear-gradient libraries.

Some adjustments also need to be made to the structure of our peerDependencies to include @tamagui and react-native as external dependencies. Since our library works in different contexts, some dependencies are not necessarily required:

// /package.json

 "peerDependencies": {
    "@tamagui/core": "1.91.4",
    "@tamagui/vite-plugin": "1.91.4",
    // The metro-plugin is responsible for making tamagui work in the react-native context.
    "@tamagui/metro-plugin": "1.91.4",
    "react": "18.2.0",
    "react-dom": "18.2.0",
    "react-native": "0.73.5"
  },
  // We will use this field to define peer dependencies that are not required.
  "peerDependenciesMeta": {
    // We will not use react-dom in the react-native context.
    "react-dom": {
      "optional": true
    },
    // We will not use react-native in the web context.
    "react-native": {
      "optional": true
    },
    // Using vite as a bundler is not mandatory.
    "@tamagui/vite-plugin": {
      "optional": true
    },
    // Using metro as a bundler is not mandatory.
    "@tamagui/metro-plugin": {
      "optional": true
    }
  },
Enter fullscreen mode Exit fullscreen mode

Let's also configure the vite.config.ts to include @tamagui/core and react-native as external dependencies:

// /vite.config.ts

    rollupOptions: {
      // Setting up the external dependencies of our library.
      external: [
        "react",
        "react/jsx-runtime",
        "react-dom",
        "react-native",
        "@tamagui/core",
      ],
Enter fullscreen mode Exit fullscreen mode

It will also be necessary to create an initial configuration file for tamagui called tamagui.config.ts. I will create this file in the src/themes folder.

// /src/themes/tamagui.config.ts
import { createTamagui } from "@tamagui/core";

// You are free to define tamagui's tokens and themes as you wish.
// In our case, we will define only two colors and two themes.
const config = createTamagui({
  fonts: {},
  shorthands: {},
  themes: {
    // It is necessary to configure at least one theme in the application.
    night: { color: "#005" },
    sun: { color: "#FA0" },
  },
  tokens: {
    color: {
      primary: "#000",
      secondary: "#FFF",
    },
    radius: {},
    size: {},
    space: {},
    zIndex: {},
  },
});

export default config;
Enter fullscreen mode Exit fullscreen mode

After defining the tokens, let's configure TypeScript to recognize the custom types defined in tamagui.config.ts by creating a file src/types.d.ts and adding the following configuration:

// src/types.d.ts

import config from "./themes/tamagui.config";

export type AppConfig = typeof config;

declare module "@tamagui/core" {
  interface TamaguiCustomConfig extends AppConfig {}
}
Enter fullscreen mode Exit fullscreen mode

We extract the config type and assign it to the TamaguiCustomConfig type. This will allow TypeScript to recognize the constant types defined in src/themes/tamagui.config.ts.

To make the tamagui token and theme system work, it is necessary to create a provider that injects the tamagui context into the application. Let's create a file called theme-provider.tsx in the src/themes folder.

// /src/themes/theme-provider.tsx
import { TamaguiProvider, TamaguiProviderProps } from "@tamagui/core";
import { PropsWithChildren } from "react";
import appConfig from "./tamagui.config";

type ThemeProviderProps = PropsWithChildren<TamaguiProviderProps>;

export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
  return (
    <TamaguiProvider config={appConfig} {...props}>
      {children}
    </TamaguiProvider>
  );
}
Enter fullscreen mode Exit fullscreen mode

This provider should be used by Storybook, tests, and the library consumer in order for the tokens defined in tamagui.config.ts to work.

👾🧪 Tamagui with Vitest

To make Tamagui work in the context of tests with Vitest, it is necessary to add the plugin responsible for processing Tamagui components and add the ThemeProvider in each render() call.

Let's install the Vite plugin and add it to vitest.config.ts:

pnpm add -D @tamagui/vite-plugin
Enter fullscreen mode Exit fullscreen mode
// /vitest.config.ts

import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react-swc";
import { createRequire } from "module";

const require = createRequire(import.meta.url);
const { tamaguiPlugin } = require("@tamagui/vite-plugin");

export default defineConfig({
  plugins: [
    react(),
    tamaguiPlugin({
      components: ["@tamagui/core"],
      // The tamagui plugin is added to the plugins section of vitest, pointing to our custom token configuration.
      config: "src/themes/tamagui.config.ts",
    }),
  ],
  test: {
    environment: "jsdom",
    globals: true,
    server: {
      // Since the tests run in the node context, this configuration is necessary to remove ESM imports and exports.
      deps: {
        inline: ["@tamagui"],
      },
    },
    include: ["src/**/*.test.ts", "src/**/*.test.tsx"],
    setupFiles: ["./src/setup-tests.ts"],
  },
});
Enter fullscreen mode Exit fullscreen mode

To add the provider to all tests in a practical way, we can create a custom render that will be used by all tests instead of the traditional render exported by the @testing-library/react library. Let's create a file in the src/__tests__/setup.tsx directory that will contain our custom render. This render should be imported instead of the render from @testing-library/react to ensure that our tests work.

// /src/__tests__/setup.tsx

import {
  queries,
  Queries,
  render as nativeRender,
  RenderOptions,
  RenderResult,
} from "@testing-library/react";
import "@testing-library/jest-dom";
import { TamaguiProvider } from "@tamagui/core";
import { ReactElement } from "react";
import config from "../themes/tamagui.config";

const render = <
  Q extends Queries = typeof queries,
  Container extends DocumentFragment | Element = HTMLElement,
  BaseElement extends DocumentFragment | Element = Container,
>(
  ui: ReactElement,
  renderOptions?: RenderOptions<Q, Container, BaseElement>
): RenderResult<Q, Container, BaseElement> =>
  nativeRender(<TamaguiProvider config={config}>{ui}</TamaguiProvider>, {
    ...renderOptions,
  });

export * from "@testing-library/react";
export { render };
Enter fullscreen mode Exit fullscreen mode

After these adjustments, our project is now configured to run tests even with Tamagui components. Finally, let's integrate our text component with Tamagui:

// /src/components/text/text.styles.ts

import { Text, styled } from "@tamagui/core";

export const StyledText = styled(Text, {
  color: "$black",
});
Enter fullscreen mode Exit fullscreen mode
// /src/components/text/text.types.tsx
import { GetProps } from "@tamagui/core";
import type { StyledText } from "./text.styles";

export type TextProps = GetProps<typeof StyledText>;
Enter fullscreen mode Exit fullscreen mode
// /src/components/text/text.tsx

import { forwardRef } from 'react';
import { TamaguiElement } from '@tamagui/core';
import type { TextProps } from './text.types';
import { StyledText } from '.';

export const Text = (props: TextProps) => (
   <StyledText {...props}>
     {children}
   </StyledText>
)
Enter fullscreen mode Exit fullscreen mode
// /src/components/text/text.test.tsx

import { Text } from "./text";

import { render, screen } from "../../__tests__/setup";

describe("[Components]: Text", () => {
  it("renders without crash", () => {
    render(<Text>Hello World</Text>);
    const component = screen.getByText("Hello World");
    expect(component).toBeDefined();
  });
});
Enter fullscreen mode Exit fullscreen mode

When running the test, you will see that the integration is already working:

âś“ src/components/text/text.test.tsx (1)
   âś“ [Components]: Text (1)
     âś“ renders without crash

 Test Files  1 passed (1)
      Tests  1 passed (1)
   Start at  21:36:14
   Duration  460ms
Enter fullscreen mode Exit fullscreen mode

đź‘ľđź’… Configuring with Storybook

To make Tamagui work within the context of Storybook, the steps are similar to those for tests. You need to inject the Tamagui plugin into Storybook and add the provider to each story.

We can add the plugin to Storybook as follows:

// /.storybook/main.ts

import { tamaguiPlugin } from "@tamagui/vite-plugin";
import type { StorybookConfig } from "@storybook/react-vite";
import tsconfigPaths from "vite-tsconfig-paths";

const config: StorybookConfig = {
  addons: [
    "@storybook/addon-links",
    "@storybook/addon-essentials",
    "@storybook/addon-interactions",
  ],
  docs: {
    autodocs: true,
  },
  env: (config) => ({
    ...config,
    // Setting the tamagui target to render for web
    TAMAGUI_TARGET: "web",
  }),
  framework: {
    name: "@storybook/react-vite",
    options: {},
  },
  stories: [
    "../src/**/*.stories.@(js|jsx|mjs|ts|tsx)",
    "../src/**/*.mdx",
    "../docs/**/*.mdx",
  ],
  viteFinal: (config, { configType }) => {
    config.define = {
      // Environment variables required for @Tamagui to work correctly
      ...config.define,
      "process.env.NODE_ENV":
        configType === "PRODUCTION" ? "production" : "development",
      "process.env.STORYBOOK": true,
    };

    config.plugins!.push(
      tamaguiPlugin({
        // Reference from the absolute path to the tamagui.config.ts
        config: "/src/themes/tamagui.config.ts",
      }),
    );

    return config;
  },
};
export default config;
Enter fullscreen mode Exit fullscreen mode
// /.storybook/preview.tsx

import type { Preview } from "@storybook/react";

import { ThemeProvider } from "../src/themes/theme-provider";

const preview: Preview = {
  decorators: [
    (Story) => (
      // We add the provider to render in all stories:
      <ThemeProvider>
        <Story />
      </ThemeProvider>
    ),
  ],
  parameters: {
    controls: {
      matchers: {
        color: /(background|color)$/i,
        date: /date$/i,
      },
    },
  },
};

export default preview;
Enter fullscreen mode Exit fullscreen mode

After these configurations, we can run pnpm storybook and see the component working in the Storybook itself.

🦋 Publishing with changesets

The changesets library is designed to automate the generation of changelogs, publishing, and version updating of the library. This ensures that users who download your design system will be able to handle version management based on semantic versions.

🧹 Cleaning up before publishing

If you tried running the library build command before reaching this step, you may have noticed that there are some files in our distribution folder related to unit tests and the Storybook itself. The build command may not have even worked on its own.

This happens because we defined all files in the /src folder as an entry point for a module in our vite.config.ts, which is causing it to also consider test and documentation files when generating the files.

We can adjust this within the computeAllSrcFiles function in vite.config.ts, configuring it to ignore all files that are not source code:

// /vite.config.ts

const computeAllSrcFiles = (): Record<string, string> => {
  // Find all .ts and .tsx files within the src folder.
  const files = glob.sync(["src/**/*.{ts,tsx}"], {
    ignore: [
    "src/**/*.stories.tsx",
  "src/**/__tests/**",
  "src/**/*.test.{ts,tsx}",
  "src/setup-tests.ts",
  "types.d.ts",
  ], // minimatch patterns we want the glob to ignore.
  });
Enter fullscreen mode Exit fullscreen mode

It is also necessary to configure the dts plugin to ignore these files in the generation of d.ts and d.ts.map files:

// vite.config.ts

// ...

export default defineConfig({
  plugins: [
    dtsPlugin({
      exclude: [
        "node_modules",     
        "src/**/*.stories.tsx",
      "src/**/__tests/**",
      "src/**/*.test.{ts,tsx}",
      "src/setup-tests.ts",
      "types.d.ts"
      ],
      include: ["src"],
    }),
  ],
  // ...
Enter fullscreen mode Exit fullscreen mode

You may also notice that some files like button.types.js were generated without any JavaScript content. This is because these files only contain type declarations in their source code, which cannot be transpiled to JavaScript.

We can remove these empty generated files by creating a custom plugin for Vite:

// vite.config.ts
const removeEmptyFiles = (): PluginOption => ({
  generateBundle(_, bundle) {
    for (const name in bundle) {
      const file = bundle[name];
      if (file.type !== "chunk") return;

      if (file.code.trim() === "") delete bundle[name];
      if (file.code.trim() === '"use strict";') delete bundle[name];
    }
  },
  name: "remove-empty-files",
});

export default defineConfig({
  plugins: [
    dtsPlugin({
      exclude: [
        "node_modules",     
        "src/**/*.stories.tsx",
        "src/**/__tests/**",
        "src/**/*.test.{ts,tsx}",
        "src/setup-tests.ts",
        "types.d.ts"
      ],
      include: ["src"],
    }),
    removeEmptyFiles(),
  ],
  // ...
Enter fullscreen mode Exit fullscreen mode

This function will check if any data chunk is empty or only contains the "use strict;" declaration. If true, this content will be removed from the final bundle.

At the end of this step, our vite.config.ts will look like this:

// /vite.config.ts

import { PluginOption, defineConfig } from "vite";
import dtsPlugin from "vite-plugin-dts";
import tsconfigPaths from "vite-tsconfig-paths";
import { dirname, extname, relative, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { glob } from "glob";

// Absolute path of the current directory from the root of the file system.
const __dirname = dirname(fileURLToPath(import.meta.url)); // /home/user/folder/library or C:\Users\user\folder\library

const computeAllSrcFiles = (): Record<string, string> => {
  // Find all .ts and .tsx files within the src folder.
  const files = glob.sync(["src/**/*.{ts,tsx}"], {
    ignore: ["src/**/*.stories.tsx", "src/**/*.test.tsx", "src/setup-tests.ts"],
  });

  const paths = files.map((file) => [
    // Remove the file extension and calculate the relative path from the src folder.
    /* key: */ relative(
      "src",
      file.slice(0, file.length - extname(file).length)
    ),

    // Convert the file path to an absolute path.
    /* value: */ fileURLToPath(new URL(file, import.meta.url)),
  ]);

  return Object.fromEntries(paths);
  // Convert the array of paths into an object.
};

const removeEmptyFiles = (): PluginOption => ({
  generateBundle(_, bundle) {
    for (const name in bundle) {
      const file = bundle[name];
      if (file.type !== "chunk") return;

      if (file.code.trim() === "") delete bundle[name];
      if (file.code.trim() === '"use strict";') delete bundle[name];
    }
  },
  name: "remove-empty-files",
});

// https://vitejs.dev/config/
export default defineConfig({
  plugins: [
    dtsPlugin({
      exclude: [
        "node_modules",
        "src/**/*.stories.tsx",
        "src/**/*.test.tsx",
        "src/setup-tests.ts",
      ],
      include: ["src"],
    }),
    removeEmptyFiles(),
  ],
  build: {
    lib: {
      // Setting the entry point of our component library.
      entry: resolve(__dirname, "src/main.ts"),
      // Setting the distribution formats of our library (CommonJS and ESM).
      formats: ["cjs", "es"],
      // Setting the output file name.
      // EntryName is the file name without the extension,
      // and format is the distribution format.
      fileName(format, entryName) {
        if (format === "es") return `${entryName}.js`;
        return `${entryName}.${format}`;
      },
    },
    rollupOptions: {
      // Setting the external dependencies of our library. (They won't be included in the bundle)
      external: [
        "react",
        "react/jsx-runtime",
        "react-dom",
        "@tamagui/core",
        "@tamagui/vite-plugin",
      ],
      input: computeAllSrcFiles(),
    },
  },
});
Enter fullscreen mode Exit fullscreen mode

Continuing...

To install the changesets library, run this command in the terminal:

pnpm install -D @changesets/cli && npx changeset init
Enter fullscreen mode Exit fullscreen mode

After installing the library, some files were generated in the .changeset folder, and some commands need to be added to our application's package.json:

// package.json
  // ...
   scripts: {
  // ...
  // The changeset library's add command creates a new version entry
  // in the .changeset folder with the major, minor, or patch scopes
  // representing semantic versioning.
  "changeset": "changeset add", 
  // The publish command is responsible for publishing the library
  // to artifacts such as npm, Azure, AWS, etc.
  "publish":  "changeset publish",
  // The version command is used to consume all the entries created
  // by the changeset command and calculate the resulting version.
  // This command also generates a CHANGELOG file containing each
  // version already published by the library.
  "version":  "changeset version"
  // ...
Enter fullscreen mode Exit fullscreen mode

We will also need to adjust our package.json to point to the correct files in our distribution folder:

// /package.json
{
  // The name to be published for your library,
  // if it's in a public registry, the name must be unique.
  "name": "library",
  // Indicates whether the library can be published to public registries:
  "private": false,
  // The changeset library will automatically increment the version
  // whenever the changeset version command is used:
  "version": "0.0.0",
  // We point to the original source code:
  "source": "./src/index.ts",
  // We point to the transpiled code in CommonJS format:
  "main": "dist/index.cjs",
  // We point to the transpiled code in ESM format:
  "module": "dist/index.js",
  // We include all the folders that should be added when publishing
  // the library:
  "files": [
    "dist",
    "src",
    "package.json",
    "CHANGELOG",
    "README.md"
  ],
  // ...
},
Enter fullscreen mode Exit fullscreen mode
// .changeset/config
{
  "$schema":  "https://unpkg.com/@changesets/config@3.0.0/schema.json",
  "changelog":  "@changesets/cli/changelog",
  "commit":  false,
  "fixed":  [],
  "linked":  [],
  "access":  "public", // To publish publicly, set it to `public`
  "baseBranch":  "main", // Set it to the name of your main branch
  "updateInternalDependencies":  "patch",
  "ignore":  []
}
Enter fullscreen mode Exit fullscreen mode

After these adjustments, you can generate a changeset using the following commands in the terminal:

╰─± pnpm changeset    
🦋  What kind of change is this for library? (current version is 0.0.0) · minor
🦋  Please enter a summary for this change (this will be in the changelogs).
🦋    (submit empty line to open external editor)
🦋  Summary · First version of my library
🦋  
🦋  === Summary of changesets ===
🦋  minor:  library
🦋  
🦋  Is this your desired changeset? (Y/n) · true
🦋  Changeset added! - you can now commit it
🦋  
🦋  If you want to modify or expand on the changeset summary, you can find it here
🦋  info /home/user/documents/library/.changeset/pink-spoons-laugh.md
Enter fullscreen mode Exit fullscreen mode

This command will generate a randomly named markdown file inside the .changeset folder called changeset. You can modify it as you prefer before actually generating a new version, and you can also accumulate as many changesets as needed before releasing new versions.

To generate a new version of the library, you should run the version command. This command will calculate how many changesets exist inside the .changeset folder and update the version key inside the package.json file, as well as document each of these files inside the CHANGELOG.md file.

pnpm run version
🦋  All files have been updated. Review them and commit at your leisure
Enter fullscreen mode Exit fullscreen mode
// / CHANGELOG.md

# library

## 0.1.0

### Minor Changes

- First version of my library
Enter fullscreen mode Exit fullscreen mode

This changelog file contains the textual content of all the changesets generated within the .changeset folder.

// /package.json
"name":  "library",
"private":  false,
"version":  "0.1.0",
Enter fullscreen mode Exit fullscreen mode

The version of our library has also been updated in the package.json, according to the semantic scope defined in each changeset (major, minor, or patch).

Finally, to publish the library, you should run the command pnpm run publish.

pnpm run publish
> changeset publish

🦋  info npm info library
🦋  warn Received 404 for npm info "library"
🦋  info library is being published because our local version (0.1.0) has not been published on npm
🦋  info Publishing "library" at "0.1.0"
Enter fullscreen mode Exit fullscreen mode

This will cause the contents specified within the files field in our package.json to be published to the npm registry. You should authenticate with npm and follow the steps provided in the terminal to publish your library. :D

Thank you for reading !!!!

You can see all the code generated during the development of this library in my Github repository:

https://github.com/alvarogfn/tamagui-design-system-vite-example

I hope this article has been helpful to you in some way, and I appreciate any suggestions for improvement!

Top comments (3)

Collapse
 
kortizti12 profile image
Kevin

Thank you for the comprehensive guide on setting up a cross-platform component library with Tamagui. Your step-by-step instructions are incredibly valuable for developers aiming to create shared components across both web and mobile platforms.

I’d like to add some insights from Mohammed Sohail's article on Tamagui, which complement your guide:

  1. Unified Approach: Sohail highlights that Tamagui addresses the challenge of cross-platform UI development by offering a unified solution for both web and mobile through React Native. This perfectly aligns with your goal of building a shared component library.
  2. Key Features: Tamagui integrates a robust styling library, an optimizing compiler, and a universal component kit. Your guide does an excellent job demonstrating how to set up and use these features effectively.
  3. Performance Optimization: Sohail emphasizes the performance benefits of Tamagui’s optimizing compiler. In benchmarks, it achieved rendering times as low as 0.02ms on the web and 108ms on native platforms, outperforming several other cross-platform libraries. This reinforces the potential performance gains when using Tamagui in your project.
  4. Tamagui Ecosystem: Your guide covers the setup of Tamagui's core packages, which aligns well with Sohail’s description of the ecosystem—consisting of @tamagui/core, @tamagui/static, and tamagui—to optimize development workflows.
  5. Framework Comparison: While your article focuses on Tamagui, Sohail’s article briefly compares it to other cross-platform frameworks like Flutter and Ionic. This broader context can be helpful for developers deciding between different solutions.
  6. Considerations and Limitations: Sohail also notes a few potential limitations of Tamagui, such as its smaller user base and possible compatibility challenges with the latest React Native versions. These are important considerations for developers embarking on a new project with Tamagui.

Your guide provides an excellent practical implementation of Tamagui, while Sohail’s article offers a broader perspective on its features and position within the cross-platform development landscape. Together, these resources give developers a well-rounded understanding of how to leverage Tamagui in their projects.

For those interested in a deeper dive, I recommend checking out this insightful article by Mohammed Sohail: Tamagui Overview. It offers an in-depth look at how Tamagui can enhance your cross-platform development efforts.

Thank you again for contributing such a detailed and helpful guide to the community!

Collapse
 
mmdf profile image
Mehmet Efe Akça

Does this require including react-native as a dependency on the web side?

Collapse
 
alvarogfn profile image
Alvaro GuimarĂŁes

it should not be required, have you encountered any problems?