Skip to main content

Component Architecture & Workflow

This document describes the architecture and workflow for React components in the Substrate project.

Technology Stack

  • UI Framework: React 19 with TypeScript
  • Component Library: shadcn/ui (wrapped)
  • Styling: Tailwind CSS with custom theme
  • Build Tool: Vite (for Storybook)
  • Component Development: Storybook with Vite
  • Testing: Jest with React Testing Library

Component Structure

We do not use ShadCN components directly in our applications. Instead, we wrap them in "Substrate" components to maintain strict control over the API and styling.

Directory Structure

  • packages/shared-components/src/shadcn: Contains the raw ShadCN UI components generated by the CLI. Do not modify these files directly unless necessary.
  • packages/shared-components/src/components: Contains the public "Substrate" wrappers. These are the components that should be exported and used by applications.

Adding a New Component

1. Generate the ShadCN Component

Run the ShadCN CLI from the packages/shared-components directory to add the base component.

cd packages/shared-components
npx shadcn@latest add [component-name]

This will place the component in src/shadcn/ui/[component-name].tsx.

2. Create the Wrapper

Create a new file in src/components/[component-name].tsx. This file should:

  1. Import the original component and its props from @shadcn/ui/[component-name].
  2. Define a new interface that extends the original props (if needed) or restricts them.
  3. Export a wrapped version of the component.

Example (src/components/button.tsx):

import * as React from "react";
import { Button as ShadButton, ButtonProps as ShadButtonProps } from "@shadcn/ui/button";

export interface ButtonProps extends ShadButtonProps {
// Add custom props or overrides here
children?: React.ReactNode;
}

export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>((props, ref) => {
return <ShadButton {...props} ref={ref} />;
});

3. Export the Component

Ensure the new component is exported from packages/shared-components/src/index.ts.

Testing Components

All components should have corresponding test files using Jest and React Testing Library. Tests should include snapshot tests using Storybook's composeStories to ensure visual consistency.

Creating Component Tests

Create a test file src/components/[component-name].spec.tsx:

import { render, screen } from "@testing-library/react";
import "@testing-library/jest-dom";
import { composeStories } from "@storybook/react";
import * as stories from "./button.stories";

const { Default, Secondary } = composeStories(stories);

describe("Button Stories", () => {
it("matches snapshot", () => {
const { container } = render(<Default />);
expect(container).toMatchSnapshot();
});

it("renders Default story", () => {
render(<Default />);
expect(screen.getByRole("button")).toHaveTextContent("Button");
});
});

describe("Button Unit Tests", () => {
it("renders children correctly", () => {
const { Button } = require("./button");
render(<Button>Click me</Button>);
expect(screen.getByRole("button")).toHaveTextContent("Click me");
});

it("handles click events", () => {
const { Button } = require("./button");
const handleClick = jest.fn();
render(<Button onClick={handleClick}>Click me</Button>);
screen.getByRole("button").click();
expect(handleClick).toHaveBeenCalledTimes(1);
});
});

Important: Snapshot tests automatically compare rendered output against snapshots checked into git. If a component changes intentionally, update snapshots with nx test shared-components -u.

Running Tests

# Run all component tests
nx test shared-components

# Run tests in watch mode
nx test shared-components --watch

# Run tests with coverage
nx test shared-components --coverage

# Update snapshots after intentional changes
nx test shared-components -u

Storybook

Components should have Storybook stories for visual testing and documentation. Storybook uses Vite for fast builds.

Creating Stories

Create a story file src/components/[component-name].stories.tsx:

import type { Meta, StoryObj } from "@storybook/react";
import { Button } from "./button";

const meta: Meta<typeof Button> = {
component: Button,
title: "Components/Button",
};

export default meta;
type Story = StoryObj<typeof Button>;

export const Primary: Story = {
args: {
children: "Button",
variant: "default",
},
};

Running Storybook

# Start Storybook dev server
nx storybook shared-components

# Build Storybook for production
nx build-storybook shared-components

Consuming Components

TypeScript Configuration

Because the shared components library uses internal aliases (specifically @shadcn/*), consuming applications must configure their tsconfig.json to resolve these paths correctly at runtime/compile time.

Add the following to the paths in your application's tsconfig.json:

{
"compilerOptions": {
"paths": {
"@shadcn/*": ["../../packages/shared-components/src/shadcn/*"]
}
}
}