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
- Use 'use client' only when using hooks or browser APIs
- Keep components focused - Single responsibility
- Use store bindings instead of hooks when possible
- Handle loading/error states - Better UX
- Register all config-driven components - In ComponentRegistry
- Use TypeScript - Define prop interfaces
- Follow naming conventions - PascalCase for components
Related Documentation
- Adding Pages - Use components in pages
- Theming - Style customization
- Data Flow - Store patterns
- Components Reference - Component catalog