Component Patterns
Pattern comuni per componenti React in elerama-frontend.
Panoramica
Questa guida documenta i pattern standard per:
- Pagine con Lista - AG Grid + filtri + CRUD
- Form con Mutation - Validazione Zod + TanStack Query
- Modali/Sheet - Creazione e modifica entità
- Gestione Stati - Loading, error, empty states
1. Pagina Lista con AG Grid
Pattern completo per pagine CRUD con griglia dati.
Struttura Base
typescript
import { AuthenticatedLayout } from "@/components/layout/authenticated";
import { useEntitiesList, useDeleteEntity } from "@/hooks/api/entities/useEntitiesApi";
import { createProtectedLoader } from "@/lib/auth/loaders";
import type { EntityListItem } from "@/schemas/entities/entities.schema";
import { AG_GRID_LOCALE_IT } from "@ag-grid-community/locale";
import type { ColDef, ICellRendererParams } from "ag-grid-community";
import {
ClientSideRowModelModule,
ModuleRegistry,
themeQuartz,
} from "ag-grid-community";
import "ag-grid-community/styles/ag-theme-quartz.css";
import { AgGridReact } from "ag-grid-react";
import { useCallback, useMemo, useState } from "react";
// Registrazione moduli AG Grid (una sola volta)
ModuleRegistry.registerModules([ClientSideRowModelModule]);
export const clientLoader = createProtectedLoader({ requireSettings: true });
export default function EntitiesPage() {
const [search, setSearch] = useState("");
const [includeDeleted, setIncludeDeleted] = useState(false);
const { data: entities, isLoading, error, refetch } = useEntitiesList({
search: search || undefined,
includeDeleted,
});
// Definizione colonne (useMemo per AG Grid)
const columnDefs = useMemo<ColDef<EntityListItem>[]>(() => [
{ field: "id", headerName: "ID", width: 80 },
{ field: "code", headerName: "Codice", flex: 1 },
{ field: "description", headerName: "Descrizione", flex: 2 },
{
headerName: "Azioni",
width: 120,
sortable: false,
filter: false,
cellRenderer: ActionsRenderer,
},
], []);
const defaultColDef = useMemo<ColDef>(() => ({
sortable: true,
filter: true,
resizable: true,
}), []);
if (error) {
return <ErrorState error={error} onRetry={refetch} />;
}
return (
<AuthenticatedLayout>
<div className="p-6">
<Card>
<CardHeader>
<CardTitle>Gestione Entità</CardTitle>
</CardHeader>
<CardContent>
{/* Filtri */}
<Filters
search={search}
onSearchChange={setSearch}
includeDeleted={includeDeleted}
onIncludeDeletedChange={setIncludeDeleted}
/>
{/* Griglia */}
<div className="ag-theme-quartz" style={{ height: 500 }}>
<AgGridReact<EntityListItem>
rowData={entities}
loading={isLoading}
columnDefs={columnDefs}
defaultColDef={defaultColDef}
theme={themeQuartz}
pagination={true}
paginationPageSize={20}
localeText={AG_GRID_LOCALE_IT}
/>
</div>
</CardContent>
</Card>
</div>
</AuthenticatedLayout>
);
}Cell Renderer per Azioni
typescript
// useCallback necessario: passato a componente third-party (AG Grid)
const ActionsRenderer = useCallback(
(params: ICellRendererParams<EntityListItem>) => {
const entity = params.data;
if (!entity) return null;
return (
<div className="flex items-center gap-1 h-full">
{entity.isDeleted ? (
<Button
variant="ghost"
size="icon"
onClick={() => handleRestore(entity)}
disabled={isMutating}
title="Ripristina"
>
<Recycle className="h-4 w-4" />
</Button>
) : (
<>
<Button
variant="ghost"
size="icon"
onClick={() => openSheet(entity)}
disabled={isMutating}
title="Modifica"
>
<Settings className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => handleDelete(entity)}
disabled={isMutating}
title="Elimina"
>
<Trash className="h-4 w-4 text-destructive" />
</Button>
</>
)}
</div>
);
},
[isMutating]
);2. Form con Validazione Zod
Pattern per form con validazione client-side e mutation.
Form State Pattern
typescript
import { EntityRequestSchema, type EntityRequest } from "@/schemas/entities/entities.schema";
import { useState } from "react";
// State per form
const [formData, setFormData] = useState<EntityRequest>({
code: "",
description: "",
});
const [formErrors, setFormErrors] = useState<Record<string, string>>({});
// Handler submit con validazione Zod
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
// Validazione con Zod
const result = EntityRequestSchema.safeParse(formData);
if (!result.success) {
const errors: Record<string, string> = {};
result.error.issues.forEach((issue) => {
if (issue.path[0]) {
errors[String(issue.path[0])] = issue.message;
}
});
setFormErrors(errors);
return;
}
// Mutation (result.data è validato)
if (editingEntity) {
updateEntity({ id: editingEntity.id, data: result.data });
} else {
createEntity(result.data);
}
};Form Fields con Error Display
typescript
<Field>
<FieldLabel htmlFor="code">Codice *</FieldLabel>
<Input
id="code"
type="text"
value={formData.code}
onChange={(e) =>
setFormData((prev) => ({
...prev,
code: e.target.value,
}))
}
placeholder="Es: SAMSUNG"
maxLength={50}
className={cn(formErrors.code && "border-destructive")}
/>
{formErrors.code && (
<p className="text-sm text-destructive mt-1">
{formErrors.code}
</p>
)}
</Field>3. Sheet per Creazione/Modifica
Pattern per side panel CRUD.
State e Handlers
typescript
import type { EntityListItem, EntityRequest } from "@/schemas/entities/entities.schema";
import { useState } from "react";
// State per sheet
const [isSheetOpen, setIsSheetOpen] = useState(false);
const [editingEntity, setEditingEntity] = useState<EntityListItem | null>(null);
const [formData, setFormData] = useState<EntityRequest>({
code: "",
description: "",
});
const [formErrors, setFormErrors] = useState<Record<string, string>>({});
// Handler apertura (nuovo o modifica)
const openSheet = (entity?: EntityListItem) => {
if (entity) {
setEditingEntity(entity);
setFormData({
code: entity.code,
description: entity.description,
});
} else {
setEditingEntity(null);
setFormData({ code: "", description: "" });
}
setFormErrors({});
setIsSheetOpen(true);
};
// Handler chiusura
const closeSheet = () => {
setIsSheetOpen(false);
setEditingEntity(null);
setFormData({ code: "", description: "" });
setFormErrors({});
};Sheet Component
typescript
import {
SheetContent,
SheetRoot,
Button,
Field,
FieldGroup,
FieldLabel,
Input,
Spinner,
} from "@elerama/ui";
<SheetRoot open={isSheetOpen} onOpenChange={setIsSheetOpen}>
<SheetContent side="right" className="w-[400px] sm:max-w-[400px]">
<div className="flex flex-col h-full">
{/* Header */}
<div className="border-b pb-4 mb-4">
<h2 className="text-lg font-semibold">
{editingEntity ? "Modifica Entità" : "Nuova Entità"}
</h2>
<p className="text-sm text-muted-foreground">
{editingEntity
? `Modifica i dati di ${editingEntity.code}`
: "Inserisci i dati della nuova entità"}
</p>
</div>
{/* Form */}
<form onSubmit={handleSubmit} className="flex-1 flex flex-col">
<FieldGroup className="flex-1">
{/* Campi form */}
</FieldGroup>
{/* Footer con bottoni */}
<div className="flex gap-2 pt-4 border-t mt-4">
<Button
type="button"
variant="outline"
onClick={closeSheet}
disabled={isPending}
className="flex-1"
>
Annulla
</Button>
<Button
type="submit"
disabled={isPending}
className="flex-1"
>
{isPending ? (
<Spinner className="h-4 w-4" />
) : editingEntity ? (
"Salva"
) : (
"Crea"
)}
</Button>
</div>
</form>
</div>
</SheetContent>
</SheetRoot>4. Mutations con Toast Feedback
Pattern per gestione operazioni CRUD con feedback.
typescript
import { useCreateEntity, useUpdateEntity, useDeleteEntity } from "@/hooks/api/entities/useEntitiesApi";
import { useToast } from "@elerama/ui";
export default function EntitiesPage() {
const { success: showSuccess, error: showError } = useToast();
// Create mutation
const { mutate: createEntity, isPending: isCreating } = useCreateEntity({
onSuccess: () => {
showSuccess("Entità creata con successo");
closeSheet();
},
onError: (error) => {
showError(error.message);
},
});
// Update mutation
const { mutate: updateEntity, isPending: isUpdating } = useUpdateEntity({
onSuccess: () => {
showSuccess("Entità aggiornata con successo");
closeSheet();
},
onError: (error) => {
showError(error.message);
},
});
// Delete mutation con conferma
const { mutate: deleteEntity, isPending: isDeleting } = useDeleteEntity({
onSuccess: () => {
showSuccess("Entità eliminata");
},
onError: (error) => {
showError(error.message);
},
});
// Handler delete con conferma
const handleDelete = (entity: EntityListItem) => {
if (window.confirm(`Sei sicuro di voler eliminare "${entity.description}"?`)) {
deleteEntity(entity.id);
}
};
// Flag combinato per disabilitare UI durante mutation
const isMutating = isCreating || isUpdating || isDeleting;
// ...
}5. Gestione Stati UI
Error State
typescript
import { Alert, AlertDescription, AlertTitle, Button } from "@elerama/ui";
import { AuthenticatedLayout } from "@/components/layout/authenticated";
interface ErrorStateProps {
error: Error;
onRetry: () => void;
}
function ErrorState({ error, onRetry }: ErrorStateProps) {
return (
<AuthenticatedLayout>
<div className="flex flex-1 items-center justify-center p-6">
<Alert variant="destructive" className="max-w-md">
<AlertTitle>Errore nel caricamento</AlertTitle>
<AlertDescription>{error.message}</AlertDescription>
<Button onClick={onRetry} className="mt-4">
Riprova
</Button>
</Alert>
</div>
</AuthenticatedLayout>
);
}Loading State
typescript
import { Spinner } from "@elerama/ui";
// Loading inline
{isLoading && <Spinner className="h-4 w-4" />}
// Loading full page
{isLoading && (
<div className="flex items-center justify-center h-64">
<Spinner className="h-8 w-8" />
</div>
)}Empty State
typescript
{data?.length === 0 && (
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
<p className="text-lg">Nessun elemento trovato</p>
<p className="text-sm">Crea il primo elemento per iniziare</p>
<Button onClick={() => openSheet()} className="mt-4">
+ Nuovo Elemento
</Button>
</div>
)}6. Filtri Lista
Search + Toggle Pattern
typescript
import { Search } from "@elerama/icons";
import { Input } from "@elerama/ui";
<div className="flex flex-wrap gap-4 mb-4">
{/* Campo ricerca */}
<div className="relative flex-1 min-w-[200px] max-w-[400px]">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
type="text"
placeholder="Cerca per codice o descrizione..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-10"
/>
</div>
{/* Toggle eliminati */}
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={includeDeleted}
onChange={(e) => setIncludeDeleted(e.target.checked)}
className="h-4 w-4 rounded border-gray-300"
/>
<span className="text-sm text-muted-foreground">
Mostra eliminati
</span>
</label>
</div>7. Status Badge Pattern
typescript
import { Check, X } from "@elerama/icons";
import { cn } from "@/lib/utils";
// Cell renderer per status
cellRenderer: (params: ICellRendererParams<EntityListItem>) => {
const isDeleted = params.value;
return (
<span
className={cn(
"inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium",
isDeleted
? "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400"
: "bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400"
)}
>
{isDeleted ? (
<>
<X className="h-3 w-3" /> Eliminato
</>
) : (
<>
<Check className="h-3 w-3" /> Attivo
</>
)}
</span>
);
}Note su useCallback/useMemo
Per via del React Compiler, NON usare useCallback/useMemo di default.
ECCEZIONI (usa quando necessario):
- Cell renderer per AG Grid (componente third-party)
- Column definitions per AG Grid
- Callbacks passati a
useEffectcome dipendenze - Props passate a componenti non compilati
typescript
// SÌ - AG Grid non è compilato da React Compiler
const columnDefs = useMemo<ColDef[]>(() => [...], []);
// SÌ - Cell renderer passato a AG Grid
const ActionsRenderer = useCallback(..., [isMutating]);
// NO - Componenti normali (React Compiler ottimizza)
// Non serve: const handleClick = useCallback(...);Checklist
- [ ] Layout corretto (
AuthenticatedLayout) - [ ] Gestione error state con retry
- [ ] Gestione loading state
- [ ] Gestione empty state
- [ ] Validazione form con Zod
- [ ] Feedback toast su mutation
- [ ] Conferma su delete
- [ ] Disabilitazione UI durante mutation
- [ ] useMemo/useCallback solo per AG Grid
Riferimenti
- Nuovo Modulo API - Come creare API hooks
- Nuova Route - Come creare route
- TanStack Query - Data fetching patterns