Skip to main content

Custom Components

Learn how to create custom components that integrate with the config-driven architecture.

Basic Component

Create a simple component:

// src/components/WelcomeCard.tsx
interface WelcomeCardProps {
title: string;
message: string;
}

export function WelcomeCard({ title, message }: WelcomeCardProps) {
return (
<div className="p-6 bg-white rounded-lg shadow">
<h2 className="text-xl font-bold">{title}</h2>
<p className="text-gray-600 mt-2">{message}</p>
</div>
);
}

Register in ComponentRegistry

Components must be registered to use in tenant configs:

// packages/client/src/config/ComponentRegistry.ts
import { WelcomeCard } from '@/components/WelcomeCard';

// Add to existing registrations
registry.set('WelcomeCard', WelcomeCard);

Use in Tenant Config

Add the component to a layout slot:

// src/eai.config/tenants/my-tenant.config.ts
{
component: 'WelcomeCard',
priority: 1,
props: {
title: 'Welcome!',
message: 'Get started with your first project.',
},
}

Store-Connected Component

Components that read from the Zustand store:

// src/components/UserGreeting.tsx
'use client';

import { useStoreValue } from '@enterpriseaigroup/client';

interface UserGreetingProps {
fallbackName?: string;
}

export function UserGreeting({ fallbackName = 'Guest' }: UserGreetingProps) {
const userName = useStoreValue('user.name');

return (
<div className="p-4 bg-blue-50 rounded-lg">
<p className="text-lg">
Hello, <strong>{userName || fallbackName}</strong>!
</p>
</div>
);
}

Store Bindings

Instead of using hooks directly, use store bindings for declarative prop injection:

// Component receives props from store
interface ProjectCardProps {
projectName: string; // Bound from store
projectStatus: string; // Bound from store
onViewClick?: () => void; // Static prop
}

export function ProjectCard({
projectName,
projectStatus,
onViewClick,
}: ProjectCardProps) {
return (
<div className="p-4 border rounded">
<h3 className="font-bold">{projectName}</h3>
<span className="text-sm text-gray-500">{projectStatus}</span>
{onViewClick && (
<button onClick={onViewClick}>View</button>
)}
</div>
);
}

Config with store bindings:

{
component: 'ProjectCard',
priority: 1,
storeBindings: [
{ prop: 'projectName', storePath: 'projects.selected.name' },
{ prop: 'projectStatus', storePath: 'projects.selected.status' },
],
}

Component with Actions

Components that write to the store:

// src/components/ThemeToggle.tsx
'use client';

import { useStoreValue, useSetStore } from '@enterpriseaigroup/client';

export function ThemeToggle() {
const theme = useStoreValue('ui.theme');
const setStore = useSetStore();

const toggleTheme = () => {
const newTheme = theme === 'light' ? 'dark' : 'light';
setStore('ui.theme', newTheme, 'ThemeToggle');
};

return (
<button
onClick={toggleTheme}
className="p-2 rounded bg-gray-100 hover:bg-gray-200"
>
{theme === 'light' ? '🌙 Dark' : '☀️ Light'}
</button>
);
}

Using Shadcn/ui Components

Build on top of existing UI primitives:

// src/components/ProjectForm.tsx
'use client';

import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Card,
CardContent,
CardFooter,
CardHeader,
CardTitle,
} from '@/components/ui/card';

interface ProjectFormProps {
onSubmit: (data: { name: string; description: string }) => void;
}

export function ProjectForm({ onSubmit }: ProjectFormProps) {
const [name, setName] = useState('');
const [description, setDescription] = useState('');

const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
onSubmit({ name, description });
};

return (
<Card>
<CardHeader>
<CardTitle>New Project</CardTitle>
</CardHeader>
<form onSubmit={handleSubmit}>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="name">Name</Label>
<Input
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Project name"
/>
</div>
<div className="space-y-2">
<Label htmlFor="description">Description</Label>
<Input
id="description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Project description"
/>
</div>
</CardContent>
<CardFooter>
<Button type="submit">Create Project</Button>
</CardFooter>
</form>
</Card>
);
}

Conditional Rendering

Use showWhen in config for conditional display:

// Config-based conditional
{
component: 'WelcomeMessage',
priority: 1,
showWhen: (state) => !state.user?.isAuthenticated,
}

Or within the component:

// Component-based conditional
export function AdminPanel() {
const userRole = useStoreValue('user.role');

if (userRole !== 'admin') {
return null;
}

return <div>Admin controls...</div>;
}

Loading States

Handle loading states in components:

// src/components/ProjectList.tsx
'use client';

import { useStoreValue } from '@enterpriseaigroup/client';
import { Skeleton } from '@/components/ui/skeleton';

export function ProjectList() {
const projects = useStoreValue('projects.list');
const isLoading = useStoreValue('projects.isLoading');

if (isLoading) {
return (
<div className="space-y-2">
<Skeleton className="h-12 w-full" />
<Skeleton className="h-12 w-full" />
<Skeleton className="h-12 w-full" />
</div>
);
}

if (!projects?.length) {
return <p className="text-gray-500">No projects yet.</p>;
}

return (
<ul className="space-y-2">
{projects.map((project) => (
<li key={project.id} className="p-4 border rounded">
{project.name}
</li>
))}
</ul>
);
}

Error Boundaries

Wrap components with error boundaries:

// src/components/ErrorBoundary.tsx
'use client';

import { Component, ReactNode } from 'react';

interface Props {
children: ReactNode;
fallback?: ReactNode;
}

interface State {
hasError: boolean;
}

export class ErrorBoundary extends Component<Props, State> {
state = { hasError: false };

static getDerivedStateFromError() {
return { hasError: true };
}

render() {
if (this.state.hasError) {
return this.props.fallback || (
<div className="p-4 bg-red-50 text-red-600 rounded">
Something went wrong.
</div>
);
}

return this.props.children;
}
}

Component TypeScript Patterns

Props Interface

interface MyComponentProps {
// Required props
title: string;
data: ProjectData[];

// Optional props with defaults
variant?: 'default' | 'compact';
showHeader?: boolean;

// Callbacks
onSelect?: (item: ProjectData) => void;

// Children
children?: React.ReactNode;
}

export function MyComponent({
title,
data,
variant = 'default',
showHeader = true,
onSelect,
children,
}: MyComponentProps) {
// ...
}

Generic Components

interface SelectProps<T> {
items: T[];
value: T | null;
onChange: (value: T) => void;
getLabel: (item: T) => string;
getValue: (item: T) => string;
}

export function Select<T>({
items,
value,
onChange,
getLabel,
getValue,
}: SelectProps<T>) {
// ...
}

Component File Structure

src/components/
├── ui/ # Shadcn primitives (don't modify)
│ ├── button.tsx
│ ├── input.tsx
│ └── ...

├── layout/ # Layout components
│ ├── Header.tsx
│ ├── Sidebar.tsx
│ └── Footer.tsx

├── features/ # Feature-specific components
│ ├── projects/
│ │ ├── ProjectCard.tsx
│ │ ├── ProjectList.tsx
│ │ └── ProjectForm.tsx
│ │
│ └── auth/
│ ├── LoginForm.tsx
│ └── UserMenu.tsx

└── shared/ # Reusable components
├── ErrorBoundary.tsx
├── LoadingSpinner.tsx
└── EmptyState.tsx

Best Practices

  1. Use 'use client' only when using hooks or browser APIs
  2. Keep components focused - Single responsibility
  3. Use store bindings instead of hooks when possible
  4. Handle loading/error states - Better UX
  5. Register all config-driven components - In ComponentRegistry
  6. Use TypeScript - Define prop interfaces
  7. Follow naming conventions - PascalCase for components