Component Structure
EverShop components follow a consistent structure:// ProductCard.tsx
import React from 'react';
// Define TypeScript interface for props
export interface ProductCardProps {
name: string;
price: number;
imageUrl: string;
onAddToCart?: () => void;
}
// Export the component function
export function ProductCard({
name,
price,
imageUrl,
onAddToCart
}: ProductCardProps) {
return (
<div className="product-card border rounded-lg p-4">
<img src={imageUrl} alt={name} className="w-full h-48 object-cover" />
<h3 className="text-lg font-semibold mt-2">{name}</h3>
<p className="text-xl font-bold">${price}</p>
{onAddToCart && (
<button
onClick={onAddToCart}
className="mt-4 w-full bg-primary text-white py-2 rounded"
>
Add to Cart
</button>
)}
</div>
);
}
Creating Components Step-by-Step
Choose the Right Location
Determine where your component belongs:Common Components (
packages/evershop/src/components/common/):- Shared across admin and storefront
- UI primitives (buttons, forms, modals)
- Utility components (loaders, notifications)
packages/evershop/src/components/admin/):- Admin panel specific
- Data tables, selectors, form controls
- Admin layout components
packages/evershop/src/components/frontStore/):- Customer-facing components
- Product displays, cart, checkout
- Storefront layout components
# For a reusable rating component
packages/evershop/src/components/common/Rating.tsx
# For an admin dashboard widget
packages/evershop/src/components/admin/DashboardWidget.tsx
# For a product review component
packages/evershop/src/components/frontStore/catalog/ProductReviews.tsx
Create the Component File
Create a TypeScript file with your component:
// packages/evershop/src/components/common/Rating.tsx
import React from 'react';
export interface RatingProps {
value: number; // Current rating (0-5)
max?: number; // Maximum rating (default: 5)
readonly?: boolean; // Whether user can change rating
onChange?: (value: number) => void;
size?: 'sm' | 'md' | 'lg';
className?: string;
}
export function Rating({
value,
max = 5,
readonly = true,
onChange,
size = 'md',
className = ''
}: RatingProps) {
const [hoveredValue, setHoveredValue] = React.useState<number | null>(null);
const sizeClasses = {
sm: 'w-4 h-4',
md: 'w-5 h-5',
lg: 'w-6 h-6'
};
const handleClick = (rating: number) => {
if (!readonly && onChange) {
onChange(rating);
}
};
return (
<div className={`flex items-center gap-1 ${className}`}>
{Array.from({ length: max }, (_, index) => {
const starValue = index + 1;
const isFilled = (hoveredValue ?? value) >= starValue;
return (
<button
key={index}
type="button"
onClick={() => handleClick(starValue)}
onMouseEnter={() => !readonly && setHoveredValue(starValue)}
onMouseLeave={() => !readonly && setHoveredValue(null)}
disabled={readonly}
className={`${
readonly ? 'cursor-default' : 'cursor-pointer hover:scale-110'
} transition-transform`}
aria-label={`Rate ${starValue} out of ${max}`}
>
<svg
className={`${sizeClasses[size]} ${
isFilled ? 'fill-amber-400 text-amber-400' : 'fill-none text-border'
}`}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth="2"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z"
/>
</svg>
</button>
);
})}
</div>
);
}
Add Styles (if needed)
For complex styling, create a companion SCSS file:Import in your component:
// Rating.scss
.rating {
display: flex;
align-items: center;
gap: 0.25rem;
&__star {
transition: transform 0.2s ease;
&:hover:not(:disabled) {
transform: scale(1.1);
}
&--filled {
fill: var(--color-amber-400);
color: var(--color-amber-400);
}
&--empty {
fill: none;
color: var(--color-border);
}
}
}
import './Rating.scss';
Use Existing UI Components
Leverage EverShop’s built-in UI components:
// ProductReviewForm.tsx
import { Form } from '@components/common/form/Form.js';
import { InputField } from '@components/common/form/InputField.js';
import { TextareaField } from '@components/common/form/TextareaField.js';
import { Button } from '@components/common/ui/Button.js';
import { Rating } from '@components/common/Rating.js';
import { useState } from 'react';
export interface ProductReviewFormProps {
productId: string;
onSuccess?: () => void;
}
export function ProductReviewForm({
productId,
onSuccess
}: ProductReviewFormProps) {
const [rating, setRating] = useState(5);
return (
<Form
action="/api/reviews"
method="POST"
onSuccess={() => {
onSuccess?.();
}}
>
<input type="hidden" name="productId" value={productId} />
<input type="hidden" name="rating" value={rating} />
<div className="mb-4">
<label className="block mb-2 font-medium">Your Rating</label>
<Rating
value={rating}
readonly={false}
onChange={setRating}
size="lg"
/>
</div>
<InputField
name="title"
label="Review Title"
required
placeholder="Summarize your experience"
/>
<TextareaField
name="comment"
label="Your Review"
required
rows={5}
placeholder="Share your thoughts about this product"
/>
<InputField
name="name"
label="Your Name"
required
placeholder="Enter your name"
/>
<Button type="submit" variant="default" size="lg">
Submit Review
</Button>
</Form>
);
}
Use React Hooks
Leverage React hooks for state and side effects:
// ProductSearch.tsx
import React, { useState, useEffect, useCallback } from 'react';
import { InputField } from '@components/common/form/InputField.js';
import { Spinner } from '@components/common/ui/Spinner.js';
import { debounce } from '@evershop/evershop/lib/util/debounce';
export interface ProductSearchProps {
onSelect: (product: Product) => void;
placeholder?: string;
}
export function ProductSearch({
onSelect,
placeholder = 'Search products...'
}: ProductSearchProps) {
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
const [isLoading, setIsLoading] = useState(false);
// Debounced search function
const searchProducts = useCallback(
debounce(async (searchQuery: string) => {
if (!searchQuery.trim()) {
setResults([]);
return;
}
setIsLoading(true);
try {
const response = await fetch(
`/api/products/search?q=${encodeURIComponent(searchQuery)}`
);
const data = await response.json();
setResults(data.products || []);
} catch (error) {
console.error('Search failed:', error);
setResults([]);
} finally {
setIsLoading(false);
}
}, 300),
[]
);
// Trigger search when query changes
useEffect(() => {
searchProducts(query);
}, [query, searchProducts]);
return (
<div className="relative">
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={placeholder}
className="w-full px-4 py-2 border rounded-lg"
/>
{isLoading && (
<div className="absolute right-3 top-3">
<Spinner width={20} height={20} />
</div>
)}
{results.length > 0 && (
<div className="absolute z-10 w-full mt-1 bg-white border rounded-lg shadow-lg max-h-64 overflow-y-auto">
{results.map((product) => (
<button
key={product.productId}
onClick={() => {
onSelect(product);
setQuery('');
setResults([]);
}}
className="w-full px-4 py-2 text-left hover:bg-muted flex items-center gap-3"
>
<img
src={product.image?.url}
alt={product.name}
className="w-10 h-10 object-cover rounded"
/>
<div>
<p className="font-medium">{product.name}</p>
<p className="text-sm text-muted-foreground">
${product.price}
</p>
</div>
</button>
))}
</div>
)}
</div>
);
}
Integrate with Area System
Make your component extensible using the Area system:
// ProductDetails.tsx
import Area from '@components/common/Area.js';
import { Image } from '@components/common/Image.js';
export interface ProductDetailsProps {
product: Product;
}
export function ProductDetails({ product }: ProductDetailsprops) {
return (
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
<div>
<Image
src={product.image.url}
width={800}
height={800}
alt={product.name}
/>
{/* Allow extensions to add content below image */}
<Area id="productImageBelow" coreComponents={[]} />
</div>
<div>
<h1 className="text-3xl font-bold">{product.name}</h1>
{/* Allow extensions to add badges, ratings, etc. */}
<Area id="productNameAfter" coreComponents={[]} />
<p className="text-2xl font-bold my-4">${product.price}</p>
<div className="prose">
{product.description}
</div>
{/* Allow extensions to add custom fields */}
<Area id="productDescriptionAfter" coreComponents={[]} />
{/* Allow extensions to modify or add to actions */}
<Area
id="productActions"
coreComponents={[
{
component: { default: AddToCartButton },
props: { product },
sortOrder: 10
}
]}
/>
</div>
</div>
);
}
Add Tests (Optional)
Create tests for your component:
// Rating.test.tsx
import { render, screen, fireEvent } from '@testing-library/react';
import { Rating } from './Rating';
describe('Rating', () => {
it('renders the correct number of stars', () => {
render(<Rating value={3} max={5} />);
const stars = screen.getAllByRole('button');
expect(stars).toHaveLength(5);
});
it('calls onChange when star is clicked', () => {
const handleChange = jest.fn();
render(
<Rating value={3} readonly={false} onChange={handleChange} />
);
const stars = screen.getAllByRole('button');
fireEvent.click(stars[4]); // Click 5th star
expect(handleChange).toHaveBeenCalledWith(5);
});
it('does not call onChange when readonly', () => {
const handleChange = jest.fn();
render(
<Rating value={3} readonly={true} onChange={handleChange} />
);
const stars = screen.getAllByRole('button');
fireEvent.click(stars[4]);
expect(handleChange).not.toHaveBeenCalled();
});
});
Export and Use Your Component
Export your component and use it throughout your application:
// In your page or other component
import { Rating } from '@components/common/Rating.js';
import { ProductReviewForm } from '@components/frontStore/catalog/ProductReviewForm.js';
export default function ProductPage({ product }) {
return (
<div>
<h1>{product.name}</h1>
{/* Show average rating */}
<Rating value={product.averageRating} readonly size="lg" />
<span>({product.reviewCount} reviews)</span>
{/* Review form */}
<div className="mt-8">
<h2>Write a Review</h2>
<ProductReviewForm
productId={product.productId}
onSuccess={() => {
window.location.reload();
}}
/>
</div>
</div>
);
}
Best Practices
TypeScript Interfaces
Always define prop interfaces:// Good
export interface ComponentProps {
title: string;
count?: number;
onAction?: () => void;
}
export function Component({ title, count = 0, onAction }: ComponentProps) {
// ...
}
// Bad
export function Component(props: any) {
// ...
}
Default Props
Use destructuring with defaults:export function Button({
variant = 'default',
size = 'md',
disabled = false,
children
}: ButtonProps) {
// ...
}
Component Naming
Use PascalCase and descriptive names:// Good
ProductCard.tsx
AddToCartButton.tsx
UserProfileForm.tsx
// Bad
productcard.tsx
btn.tsx
form1.tsx
Import Paths
Use path aliases and include.js extension:
// Good
import { Button } from '@components/common/ui/Button.js';
import { useCartState } from '@components/frontStore/cart/CartContext.js';
// Bad
import { Button } from '../../../common/ui/Button';
import { useCartState } from '../../cart/CartContext.tsx';
Accessibility
Make components accessible:<button
onClick={handleClick}
aria-label="Add to cart"
aria-disabled={isDisabled}
disabled={isDisabled}
>
Add to Cart
</button>
<input
type="text"
id="email"
aria-invalid={hasError ? 'true' : 'false'}
aria-describedby={hasError ? 'email-error' : undefined}
/>
{hasError && <span id="email-error">{errorMessage}</span>}
Error Handling
Handle errors gracefully:export function ProductList({ products }: ProductListProps) {
const [error, setError] = useState<string | null>(null);
if (error) {
return (
<div className="text-destructive p-4 border border-destructive rounded">
<p>Failed to load products: {error}</p>
<button onClick={() => setError(null)}>Retry</button>
</div>
);
}
// ... rest of component
}
Performance Optimization
Use React.memo for expensive components:import React from 'react';
interface ProductCardProps {
product: Product;
onAddToCart: (sku: string) => void;
}
export const ProductCard = React.memo(function ProductCard({
product,
onAddToCart
}: ProductCardProps) {
return (
<div className="product-card">
{/* ... */}
</div>
);
});
const handleAddToCart = useCallback(() => {
dispatch(addToCart(product.sku));
}, [product.sku, dispatch]);
Common Patterns
Render Props Pattern
export function DataLoader<T>({
url,
children
}: {
url: string;
children: (data: T | null, loading: boolean, error: string | null) => ReactNode;
}) {
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
fetch(url)
.then(res => res.json())
.then(setData)
.catch(err => setError(err.message))
.finally(() => setLoading(false));
}, [url]);
return <>{children(data, loading, error)}</>;
}
// Usage
<DataLoader<Product[]> url="/api/products">
{(products, loading, error) => (
loading ? <Spinner /> :
error ? <ErrorMessage error={error} /> :
<ProductList products={products} />
)}
</DataLoader>
Compound Components Pattern
export function Card({ children, className }: CardProps) {
return <div className={`card ${className}`}>{children}</div>;
}
Card.Header = function CardHeader({ children }: { children: ReactNode }) {
return <div className="card-header">{children}</div>;
};
Card.Body = function CardBody({ children }: { children: ReactNode }) {
return <div className="card-body">{children}</div>;
};
Card.Footer = function CardFooter({ children }: { children: ReactNode }) {
return <div className="card-footer">{children}</div>;
};
// Usage
<Card>
<Card.Header>
<h3>Product Title</h3>
</Card.Header>
<Card.Body>
<p>Product description...</p>
</Card.Body>
<Card.Footer>
<Button>Add to Cart</Button>
</Card.Footer>
</Card>
Related Resources
Component Overview
Learn about the component system
Admin Components
Browse admin components
Storefront Components
Explore storefront components