Best practices

Patterns and guidelines for building scalable, maintainable interfaces with UI Lab components.

Component composition

Use compound components effectively

Components like Card, Dialog, and Tabs use the compound component pattern. Leverage their slots to build consistent layouts:

import { Card, Button } from '@ui-lab/core';

// ✅ Use compound components
export default function UserCard({ user }) {
  return (
    <Card>
      <Card.Header>
        <Card.Title>{user.name}</Card.Title>
        <Card.Description>{user.email}</Card.Description>
      </Card.Header>
      <Card.Content>
        {/* Content here */}
      </Card.Content>
      <Card.Footer>
        <Button>Edit</Button>
      </Card.Footer>
    </Card>
  );
}

This approach maintains consistent spacing and semantic structure across your app.

Wrap components for project-specific needs

Create wrapper components for common patterns in your project:

// components/PrimaryButton.tsx
import { Button, type ButtonProps } from '@ui-lab/core';

export default function PrimaryButton(props: ButtonProps) {
  return <Button variant="primary" size="md" {...props} />;
}

// components/FormField.tsx
import { Label, Input } from '@ui-lab/core';

interface FormFieldProps {
  label: string;
  error?: string;
  [key: string]: any;
}

export default function FormField({
  label,
  error,
  ...inputProps
}: FormFieldProps) {
  return (
    <div>
      <Label htmlFor={inputProps.id}>{label}</Label>
      <Input
        aria-invalid={!!error}
        aria-describedby={error ? `${inputProps.id}-error` : undefined}
        {...inputProps}
      />
      {error && (
        <p id={`${inputProps.id}-error`} className="text-destructive-500 text-sm mt-1">
          {error}
        </p>
      )}
    </div>
  );
}

Wrapper components let you enforce patterns and reduce repetition without forking component source.

State management

Keep component state simple

UI Lab components are controlled components. Manage form state with React hooks or your state management solution:

import { useState } from 'react';
import { Button, Input, Card } from '@ui-lab/core';

export default function ContactForm() {
  const [formData, setFormData] = useState({
    name: '',
    email: '',
    message: ''
  });

  const [isSubmitting, setIsSubmitting] = useState(false);
  const [errors, setErrors] = useState<Record<string, string>>({});

  const handleSubmit = async (e) => {
    e.preventDefault();
    setIsSubmitting(true);

    try {
      const response = await fetch('/api/contact', {
        method: 'POST',
        body: JSON.stringify(formData),
      });

      if (!response.ok) {
        setErrors({ submit: 'Failed to send message' });
      }
    } finally {
      setIsSubmitting(false);
    }
  };

  const handleChange = (field: string) => (e) => {
    setFormData(prev => ({
      ...prev,
      [field]: e.target.value
    }));
    // Clear error when user starts typing
    if (errors[field]) {
      setErrors(prev => ({
        ...prev,
        [field]: ''
      }));
    }
  };

  return (
    <Card>
      <Card.Header>
        <Card.Title>Contact us</Card.Title>
      </Card.Header>
      <form onSubmit={handleSubmit}>
        <Card.Content className="space-y-4">
          <Input
            placeholder="Your name"
            value={formData.name}
            onChange={handleChange('name')}
          />
          <Input
            type="email"
            placeholder="your@email.com"
            value={formData.email}
            onChange={handleChange('email')}
          />
          <textarea
            placeholder="Your message"
            value={formData.message}
            onChange={handleChange('message')}
            className="w-full p-2 rounded border border-background-700"
          />
        </Card.Content>
        <Card.Footer>
          <Button type="submit" disabled={isSubmitting}>
            {isSubmitting ? 'Sending...' : 'Send'}
          </Button>
        </Card.Footer>
      </form>
    </Card>
  );
}

Separate concerns with custom hooks

Extract form logic into custom hooks for reuse:

// hooks/useForm.ts
import { useState } from 'react';

export function useForm(onSubmit) {
  const [values, setValues] = useState({});
  const [errors, setErrors] = useState({});
  const [isSubmitting, setIsSubmitting] = useState(false);

  const handleChange = (e) => {
    const { name, value } = e.target;
    setValues(prev => ({ ...prev, [name]: value }));
    if (errors[name]) {
      setErrors(prev => ({ ...prev, [name]: '' }));
    }
  };

  const handleSubmit = async (e) => {
    e.preventDefault();
    setIsSubmitting(true);
    await onSubmit(values);
    setIsSubmitting(false);
  };

  return { values, errors, isSubmitting, handleChange, handleSubmit };
}

// Usage in component
import { useForm } from '@/hooks/useForm';
import { Card, Button, Input } from '@ui-lab/core';

export default function SignupForm() {
  const { values, handleChange, handleSubmit, isSubmitting } = useForm(
    async (data) => {
      await fetch('/api/signup', {
        method: 'POST',
        body: JSON.stringify(data),
      });
    }
  );

  return (
    <form onSubmit={handleSubmit}>
      <Input name="email" value={values.email} onChange={handleChange} />
      <Button type="submit" disabled={isSubmitting}>Sign up</Button>
    </form>
  );
}

Performance optimization

Use React.memo for list items

When rendering long lists, memoize list items to prevent unnecessary re-renders:

import { memo } from 'react';
import { Card, Button } from '@ui-lab/core';

const ListItem = memo(function ListItem({ item, onDelete }) {
  return (
    <Card className="flex items-center justify-between p-4">
      <div>
        <p className="font-semibold">{item.name}</p>
        <p className="text-foreground-400 text-sm">{item.description}</p>
      </div>
      <Button variant="destructive" onClick={() => onDelete(item.id)}>
        Delete
      </Button>
    </Card>
  );
});

export default function ItemList({ items, onDelete }) {
  return (
    <div className="space-y-2">
      {items.map(item => (
        <ListItem key={item.id} item={item} onDelete={onDelete} />
      ))}
    </div>
  );
}

Lazy load dialogs and modals

Use dynamic imports for heavy components shown conditionally:

import { lazy, Suspense, useState } from 'react';
import { Button } from '@ui-lab/core';

const SettingsDialog = lazy(() => import('./SettingsDialog'));

export default function App() {
  const [showSettings, setShowSettings] = useState(false);

  return (
    <>
      <Button onClick={() => setShowSettings(true)}>
        Settings
      </Button>
      {showSettings && (
        <Suspense fallback={<div>Loading...</div>}>
          <SettingsDialog onClose={() => setShowSettings(false)} />
        </Suspense>
      )}
    </>
  );
}

Minimize className computations

Avoid computing className inline. Move it outside render:

// ❌ Don't do this - className computed every render
export default function Item({ isActive }) {
  return (
    <div className={isActive ? 'bg-accent-500' : 'bg-background-800'}>
      Item
    </div>
  );
}

// ✅ Do this - className computed once
const itemClassName = (isActive: boolean) =>
  isActive ? 'bg-accent-500' : 'bg-background-800';

export default function Item({ isActive }) {
  return <div className={itemClassName(isActive)}>Item</div>;
}

TypeScript best practices

Type component props properly

Always define props interfaces for better type safety and documentation:

import { Button, Card } from '@ui-lab/core';

interface UserCardProps {
  userId: string;
  onEdit?: (id: string) => void;
  onDelete?: (id: string) => void;
}

export default function UserCard({
  userId,
  onEdit,
  onDelete,
}: UserCardProps) {
  return (
    <Card>
      <Card.Header>
        <Card.Title>User #{userId}</Card.Title>
      </Card.Header>
      <Card.Footer className="gap-2">
        {onEdit && <Button onClick={() => onEdit(userId)}>Edit</Button>}
        {onDelete && (
          <Button variant="destructive" onClick={() => onDelete(userId)}>
            Delete
          </Button>
        )}
      </Card.Footer>
    </Card>
  );
}

Extend component prop types

Reuse UI Lab component types when creating wrappers:

import { Button, type ButtonProps } from '@ui-lab/core';

interface PrimaryButtonProps extends ButtonProps {
  icon?: React.ReactNode;
}

export default function PrimaryButton({
  icon,
  ...props
}: PrimaryButtonProps) {
  return (
    <Button variant="primary" {...props}>
      {icon && <span className="mr-2">{icon}</span>}
      {props.children}
    </Button>
  );
}

Responsive design

Use Tailwind responsive prefixes

Build responsive layouts with Tailwind's responsive utilities:

import { Card, Button } from '@ui-lab/core';

export default function Dashboard() {
  return (
    <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
      <Card>
        <Card.Header>
          <Card.Title className="text-lg md:text-xl">
            Revenue
          </Card.Title>
        </Card.Header>
        <Card.Content>
          <p className="text-2xl md:text-3xl font-bold">$4,231</p>
        </Card.Content>
      </Card>
      {/* More cards */}
    </div>
  );
}

Test on real devices

Browser DevTools responsive mode is helpful, but test on real devices. Common breakpoints:

  • sm: 640px — Small phones
  • md: 768px — Tablets
  • lg: 1024px — Laptops
  • xl: 1280px — Desktops

Common patterns

Conditional rendering

Use concise conditional rendering:

// ✅ Good
{isLoading && <div>Loading...</div>}
{error && <p className="text-destructive-500">{error}</p>}
{data && <Card>{data.name}</Card>}

// ❌ Avoid
{isLoading ? <div>Loading...</div> : null}
{error ? <p>{error}</p> : null}
{data ? <Card>{data.name}</Card> : null}

Event handler naming

Name handlers consistently with handle prefix:

export default function Form() {
  const handleSubmit = (e) => { /* ... */ };
  const handleReset = () => { /* ... */ };
  const handleInputChange = (e) => { /* ... */ };
  const handleDelete = (id) => { /* ... */ };

  return (
    <form onSubmit={handleSubmit}>
      <Input onChange={handleInputChange} />
      <Button type="submit">Submit</Button>
      <Button onClick={handleReset}>Reset</Button>
      <Button onClick={() => handleDelete(id)}>Delete</Button>
    </form>
  );
}