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:
- Import the original component and its props from
@shadcn/ui/[component-name]. - Define a new interface that extends the original props (if needed) or restricts them.
- 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/*"]
}
}
}
Related Documentation
- Shared Components Package - Package documentation
- Testing Documentation - Testing guidelines