Unified Create and Edit Form With NextJS
Source: Dev.to
Introduction
In this guide we’ll create a unified form that can handle both adding new data and updating existing data. Using a single form reduces code duplication, makes maintenance easier, ensures consistency in data handling and validation, improves the user experience, and speeds up development.
We’ll walk through how to create a form for adding and editing branches.
Branch Instance Type
export type BranchInstance = {
id: string;
branch_name: string;
location: string;
img: string;
google_coordinate: string;
};
Validation Schema (Zod)
import { z } from "zod";
// validator
export const BranchSchema = z.object({
branch_name: z.string().min(1, "Branch name is required"),
location: z.string().min(1, "Location is required"),
google_coordinate: z.string().min(1, "Google Map embed link is required"),
img: z.string().min(1, "One Image of the place is required"),
});
// Infer the TypeScript type from the Zod schema
type BranchSchemaType = z.infer<typeof BranchSchema>;
// Errors type
export type zodBranchErrorsType = {
[K in keyof BranchSchemaType]?: string[];
};
Save Response Types
export type BranchSaveResponse = {
status: number;
data: BranchInstance;
message: string;
errors?: {
fieldErrors?: zodBranchErrorsType;
formErrors?: string[];
};
};
// Generic reusable version
export type SaveResponse<T> = {
status: number;
data: T;
message: string;
errors?: {
fieldErrors?: Record<string, string[]>;
formErrors?: string[];
};
};
export type BranchSaveResponse = SaveResponse<BranchInstance>;
Converting FormData to a Plain Object
export function formDataToObject(fd: FormData) {
// This object will hold the final key‑value pairs
const out: Record<string, any> = {};
// This set keeps track of all keys that are arrays (ending with "[]")
const arrayKeys = new Set<string>();
// Loop through all key‑value pairs in the FormData
for (const [rawKey, value] of fd.entries()) {
// Check if the field name ends with "[]", which means it's an array field
const isArray = rawKey.endsWith("[]");
// Remove the "[]" from the key for cleaner object property names
const key = isArray ? rawKey.slice(0, -2) : rawKey;
// Remember that this key is meant to represent an array
if (isArray) arrayKeys.add(key);
// If this key already exists in the object, we need to merge the new value
if (key in out) {
const prev = out[key];
// If it's already an array, add the new value to it;
// otherwise, convert the previous single value into an array
out[key] = Array.isArray(prev) ? [...prev, value] : [prev, value];
} else {
out[key] = value;
}
}
// Ensure that all keys marked as arrays are stored as arrays,
// even if only one value was present in the FormData
for (const key of arrayKeys) {
const v = out[key];
out[key] = Array.isArray(v) ? v : v === undefined ? [] : [v];
}
// Return the final, cleaned‑up object
return out;
}
Reusable Input Component
export function InputField({
label,
name,
defaultValue,
errors,
placeholder,
type = "text", // default is text
}: {
label: string;
name: string;
defaultValue?: string | number | null;
errors?: string[];
placeholder?: string;
type?: "text" | "number" | "email" | "password";
}) {
return (
<div className="mb-4">
<label htmlFor={name} className="block text-sm font-medium text-gray-700">
{label}
</label>
<input
id={name}
name={name}
type={type}
className={`mt-1 block w-full rounded-md shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm ${
errors?.length ? "border-red-500" : "border-gray-300"
}`}
defaultValue={defaultValue as string | number}
placeholder={placeholder}
/>
{errors?.map((error, index) => (
<p key={index} className="mt-1 text-sm text-red-600">
{error}
</p>
))}
</div>
);
}
Image Uploader Component
import { useState, useRef, useCallback } from "react";
import toast from "react-hot-toast";
const UploadIcon: React.FC<{ className?: string }> = ({ className }) => (
<svg
className={className}
/* SVG content omitted for brevity */
/>
);
const CloseIcon: React.FC<{ className?: string }> = ({ className }) => (
<svg
className={className}
/* SVG content omitted for brevity */
/>
);
function ImageUploader({
label,
name,
errors,
initial,
}: {
label: string;
name: string;
errors: string[];
initial?: string; // if provided, the image is in update state
}) {
const [image, setImage] = useState<File | null>(null);
const [isDragging, setIsDragging] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const [imageId, setImageId] = useState<string | null>(null);
// Used only if `initial` is provided
const [markedDeletedId, setMarkedDeletedId] = useState<string[]>([]);
const processFile = useCallback(async (file: File) => {
if (file && file.type.startsWith("image/")) {
/** upload to Appwrite */
toast.loading("Uploading main image", { id: "upload" });
// ...upload logic here...
}
}, []);
// Component UI (simplified)
return (
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700">{label}</label>
{/* UI for drag‑and‑drop, preview, delete, etc. */}
{/* Errors */}
{errors.map((e, i) => (
<p key={i} className="mt-1 text-sm text-red-600">
{e}
</p>
))}
{/* Hidden inputs to send image ID and deleted IDs to the server */}
<input type="hidden" name={`${name}_id`} value={imageId ?? ""} />
{markedDeletedId.map((id) => (
<input key={id} type="hidden" name={`${name}_deleted_ids[]`} value={id} />
))}
</div>
);
}
Note: This method of saving files (images, PDFs, DOCX, etc.) is safer than uploading directly to the server. Only the file identifier is sent to the backend, reducing exposure and avoiding file‑size limits.