Skip to main content

Example of Zod and React Hook Form Usage

Below is a complete example of a client component (EditEntityForm.tsx) that demonstrates how to use Zod for validation and React Hook Form for form handling, along with React Query for data mutations:

'use client';

import { updateEntity } from '@/lib/actions/Entities';
import { ContentEntity } from '@/lib/types/types';
import { useMutation } from '@tanstack/react-query';
import { useRouter } from 'next/navigation';
import React, { useEffect, useState } from 'react';
import { toast } from 'sonner';
import { z } from 'zod';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { Label } from '@/components/ui/label';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import Loader from '@/components/Loader';
import { createClient } from '@/utils/supabase/client';
import { FaRegTrashAlt } from 'react-icons/fa';

// Define Zod schema for form validation
const entitySchema = z.object({
name: z.string().min(1, 'Entity name is required'),
description: z.string().optional(),
content: z.string().optional(),
published: z.boolean().optional(),
metadata: z.record(z.string()).optional(),
image_url: z.string().nullable(),
});

type EntityFormData = z.infer<typeof entitySchema>;

const EditEntityForm = ({
entityData,
userId,
}: {
entityData: ContentEntity;
userId: string;
}) => {
const router = useRouter();
const [isPageLoading, setIsPageLoading] = useState(true);
const [isSubmitting, setIsSubmitting] = useState(false);
const supabase = createClient();
const [file, setFile] = useState<File | null>(null); // Store selected image file
const [imageUrl, setImageUrl] = useState<string | null>(
entityData.image_url || null
); // State for image URL

// React Query mutation for updating entity
const {
data: response_updateEntity,
mutate: server_updateEntity,
isPending: loading_updateEntity,
error: error_updateEntity,
} = useMutation({
mutationFn: updateEntity,
onSuccess: () => {
router.refresh();
toast.success(
response_updateEntity?.message || 'Entity updated successfully'
);
setIsSubmitting(false);
router.push('/entities/details/' + entityData.id);
},
onError: (error: any) => {
console.error('Error updating entity:', error);
toast.error(error?.message || 'Error updating entity');
},
});

// React Hook Form setup with Zod validation
const {
register,
handleSubmit,
formState: { errors },
setValue, // Import setValue from useForm
} = useForm<EntityFormData>({
resolver: zodResolver(entitySchema),
defaultValues: {
name: entityData?.name,
description: entityData?.description || '',
content: entityData?.content || '',
published: entityData?.published || false,
metadata: entityData?.metadata || {},
image_url: imageUrl || null,
},
});

// Handle form submission
const onSubmit = async (formData: EntityFormData) => {
try {
setIsSubmitting(true);

// Handle new image upload
if (file) {
const uploadedImageUrl = await uploadImage();
console.log('Uploaded image URL:', uploadedImageUrl);

if (uploadedImageUrl) {
formData.image_url = uploadedImageUrl;
setImageUrl(uploadedImageUrl); // Update local image URL state
setValue('image_url', uploadedImageUrl); // Update form value
}
} else if (!imageUrl) {
formData.image_url = null;
}

// Call the mutation with validated data
server_updateEntity({
updatedEntity: formData,
entityId: entityData.id!,
});
} catch (error) {
console.error('Error in onSubmit:', error);
toast.error('An error occurred while updating the entity.');
}
};

// Handle image file change
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files) {
console.log('Selected file:', e.target.files[0]);
setFile(e.target.files[0]);
}
};

const handleRemoveImage = async () => {
// Update the form data to remove the image
if (imageUrl) {
setImageUrl(null);
setValue('image_url', null); // Update form value
setFile(null);
}
};

// Upload image to Supabase storage
const uploadImage = async (): Promise<string | null> => {
if (!file) return null;

const { data, error } = await supabase.storage
.from('entity-images') // Ensure the "entity-images" bucket exists in Supabase
.upload(`${userId}/${Date.now()}_${file.name}`, file);

if (error) {
console.error('Error uploading image:', error);
toast.error('Failed to upload image');
return null;
}

// Get the public URL of the uploaded file
const { data: urlData } = supabase.storage
.from('entity-images')
.getPublicUrl(data.path);

return urlData.publicUrl;
};

useEffect(() => {
setIsPageLoading(false);
}, []);

if (isPageLoading) {
return <Loader />;
}

return (
<section className="bg-white dark:bg-gray-900">
<div className="py-8 px-4 mx-auto max-w-2xl lg:py-16">
<h2 className="text-2xl font-bold mb-6">Edit Entity</h2>
<form onSubmit={handleSubmit(onSubmit)}>
{/* Entity Name */}
<div className="mb-4">
<Label htmlFor="name">Entity Name</Label>
<Input
id="name"
{...register('name')}
placeholder="Enter entity name"
className="mt-1"
/>
{errors.name && (
<p className="text-red-600">{errors.name.message}</p>
)}
</div>

{/* Description */}
<div className="mb-4">
<Label htmlFor="description">Description</Label>
<Textarea
id="description"
{...register('description')}
placeholder="Enter description"
className="mt-1"
/>
{errors.description && (
<p className="text-red-600">{errors.description?.message}</p>
)}
</div>

{/* Content */}
<div className="mb-4">
<Label htmlFor="content">Content</Label>
<Textarea
id="content"
{...register('content')}
placeholder="Enter content"
className="mt-1"
/>
{errors.content && (
<p className="text-red-600">{errors.content?.message}</p>
)}
</div>

{/* Image */}
<div className="sm:col-span-2">
<label className="block mb-2 text-sm font-medium text-gray-900 dark:text-white">
Image
</label>
<input
className="block w-full mb-2 text-xs text-gray-900 border border-gray-300 rounded-lg cursor-pointer bg-gray-50 dark:text-gray-400 focus:outline-none dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400"
id="image_url"
type="file"
accept="image/*"
onChange={handleFileChange}
/>
{imageUrl && (
<div className="flex items-center mt-2 group relative">
<img
src={imageUrl}
alt="Entity Image"
className="w-auto h-auto object-cover mr-4"
/>
<Button
variant={'destructive'}
size={'icon'}
type="button"
onClick={handleRemoveImage}
className="hidden absolute top-0 left-0 group-hover:flex"
>
<FaRegTrashAlt />
</Button>
</div>
)}
</div>

{/* Submit Button */}
<Button type="submit" className="w-full" disabled={isSubmitting}>
{isSubmitting ? 'Updating...' : 'Update Entity'}
</Button>

{error_updateEntity && (
<div className="mt-4 text-red-600">
{error_updateEntity.message}
</div>
)}
</form>
</div>
</section>
);
};

export default EditEntityForm;