DEV Community

Cover image for Unified Create and Edit Form With NextJS
Butch Imperial
Butch Imperial

Posted on

Unified Create and Edit Form With NextJS

In this topic, we’re going to create a form that can handle both adding new data and updating existing data. This is just a guide to help you build a unified form for both creating and editing entries.

Using a single, unified form brings several benefits:

  • It reduces code duplication
  • Makes maintenance easier
  • Ensures consistency in data handling and validation
  • Improves the user experience by providing a familiar interface for both creating and editing
  • Speeds up development since you don’t need to build separate forms for each action.

For example, we’ll walk through how to create a form for adding and editing branches

1. Create the necessary types and functions.

First, we will create the instance of the branch.

export type BranchInstance = {
   id: string,
   branch_name: string,
   location: string,
   img: string,
   google_coordinate: string
}
Enter fullscreen mode Exit fullscreen mode

Second, we’ll create a Zod validation schema for the branch instance. You can use any validation library you prefer, but in this example, we’ll use Zod. The goal is to validate every field in the branch instance, so all of its fields will be included in the Zod object.

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>;

// for errors type
export type zodBranchErrorsType = {
  [K in keyof BranchSchemaType]?: string[];
};
Enter fullscreen mode Exit fullscreen mode

Third, we’ll create a save response object. This should include the status, message, errors, and the returned data or value. We’ll use this structure as the response for useActionState.

export type BranchSaveResponse = {
   status: number,
   data: BranchInstance
   message: string,
   errors?: {
     fieldErrors?: zodBranchErrorsType;
     formErrors?: string[];
  },
}
Enter fullscreen mode Exit fullscreen mode

You can do the generic version for reusability

export type SaveResponse<T> = {
  status: number;
  data: T;
  message: string;
  errors?: {
    fieldErrors?: Record<string, any>; // Or a generic type if you want
    formErrors?: string[];
  };
};

export type BranchSaveResponse = SaveResponse<BranchInstance>;
Enter fullscreen mode Exit fullscreen mode

Fourth, create a function that converts form data into a regular JavaScript object so it’s easier to work with and manipulate. Paste the code to AI to explain further.

export function formDataToObject(fd: FormData) {
  // This object will hold the final key-value pairs
  const out: Record<string, FormDataEntryValue | FormDataEntryValue[]> = {};

  // 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 {
      // Otherwise, just set the key to this value
      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;
}
Enter fullscreen mode Exit fullscreen mode

2. Create a reusable component.

Since the branch form contains fields that is text and image, we will create a component for each to maximize re-usability.

First, we have the input component for text, number, email, and password fields. In our case, we only need the text input, but you can reuse this component for number, email, or password as well. The key props are name, which is used by the server to identify the field; defaultValue, which sets the field’s initial value; and errors, which is an array if it’s empty, that means there are no validation error

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, // optional placeholder
  type?: "text" | "number" | "email" | "password", 
}) {
  return (
    <div className="flex flex-col gap-2">
      <label htmlFor={name} className="label">{label}</label>
      <input
        type={type}
        id={name}
        name={name}
        className={"input border " + ((errors?.length ?? 0) > 0 ? " input-error" : "input-neutral border-gray-300")}
        defaultValue={defaultValue as string | number}
        placeholder={placeholder}
      />
      {errors?.map((error, index) => (
        <p key={index} className="text-sm text-red-500 mt-1">{error}</p>
      ))}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Next, we’ll create an image component. This component will upload the dropped image to Appwrite storage using an action function, and it will only store the image ID returned by Appwrite. The data sent to the server is just a string not the actual image file. This helps protect the server and avoids hitting file upload limits.

In edit mode, the component keeps the previous image ID while allowing the user to upload a new one. The key detail is that if the user removes the previous image, its ID is saved in a hidden input field for example, if the image field is named image, the removed image ID is stored as imageMarkedID. You can paste this code into AI tools to help understand how it works.

*Note: This method of saving files (images, PDFs, DOCX, etc.) is safer than uploading directly to the server. The key is how the file is sent and stored. *

const UploadIcon: React.FC<{ className?: string }> = ({ className }) => (
    <svg
        className={className}
        stroke="currentColor"
        fill="none"
        strokeWidth="2"
        viewBox="0 0 24 24"
        strokeLinecap="round"
        strokeLinejoin="round"
        xmlns="http://www.w3.org/2000/svg"
    >
        <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
        <polyline points="17 8 12 3 7 8"></polyline>
        <line x1="12" y1="3" x2="12" y2="15"></line>
    </svg>
);

const CloseIcon: React.FC<{ className?: string }> = ({ className }) => (
    <svg
        className={className}
        stroke="currentColor"
        fill="currentColor"
        strokeWidth="0"
        viewBox="0 0 512 512"
        height="1em"
        width="1em"
        xmlns="http://www.w3.org/2000/svg"
    >
        <path d="M289.94 256l95-95A24 24 0 00351 127l-95 95-95-95a24 24 0 00-34 34l95 95-95 95a24 24 0 1034 34l95-95 95 95a24 24 0 0034-34z"></path>
    </svg>
);

function ImageUploader({
    label, name, errors, initial
}: {
    label: string,
    name: string,
    errors: string[],
    initial?: string /* if provided, then the image is in update state */
}) {
    const [image, setImage] = useState<string | null>(null);
    const [isDragging, setIsDragging] = useState<boolean>(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 upwrite */
            toast.loading("Uploading main image", { id: "uploadMainImage" })

            const { success, id, error } = await uploadImageAction(file);

            if (success) {
                setImageId(id)
                console.log("ImageID: ", imageId)
                toast.success("Uploaded successfully!", { id: "uploadMainImage" })

                const reader = new FileReader();
                reader.onloadend = () => {
                    setImage(reader.result as string);
                };

                reader.readAsDataURL(file);
            } else {
                console.log(error)
                toast.error(error, { id: "uploadMainImage" })
                setImage(null)
                if (fileInputRef.current) {
                    fileInputRef.current.value = '';
                }

            }

        } else {
            // You can add more robust error handling here
            alert('Invalid file type. Please upload an image.');
        }
    }, []);

    const handleDragOver = useCallback((e: React.DragEvent<HTMLDivElement>) => {
        e.preventDefault();
        e.stopPropagation();
        setIsDragging(true);
    }, []);

    const handleDragLeave = useCallback((e: React.DragEvent<HTMLDivElement>) => {
        e.preventDefault();
        e.stopPropagation();
        setIsDragging(false);
    }, []);

    const handleDrop = useCallback((e: React.DragEvent<HTMLDivElement>) => {
        e.preventDefault();
        e.stopPropagation();
        setIsDragging(false);
        const files = e.dataTransfer.files;
        if (files && files.length > 0) {
            processFile(files[0]);
        }

    }, [processFile]);

    const handleClick = () => {
        fileInputRef.current?.click();
    };

    const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
        const files = e.target.files;
        if (files && files.length > 0) {
            processFile(files[0]); // displays the image to UI and upload it to appwrite
        }
    };

    const handleRemoveImage = async (e: React.MouseEvent<HTMLButtonElement>) => {
        e.stopPropagation();

        // if initial is provided, then we are in update mode, defer the deletion
        if (initial) {
            // save the marked delete to a state
            setMarkedDeletedId([...markedDeletedId, imageId])

            setImage(null)
            if (fileInputRef.current) {
                fileInputRef.current.value = '';
            }
            setImageId(null)

            return
        }

        toast.loading("Deleting Main image", { id: "deleteMainImage" })

        const { success, error } = await deleteImageAction(imageId)

        if (success) {
            setImage(null)
            if (fileInputRef.current) {
                fileInputRef.current.value = '';
            }
            setImageId(null)

            toast.success("Deleted successfully!", { id: "deleteMainImage" })
        } else {

            toast.error(error, { id: "deleteMainImage" })
        }

    };

    React.useEffect(() => {
        async function init() {
            if (initial) {

                try {

                    const url = await getImageAction(initial)
                    setImageId(initial)
                    setImage(url)

                } catch (e) {
                    console.log("Error parsing initial value for image uploader:", e)
                    throw new Error("Something went wrong wrong getting the image url", e)
                }
            }
        }
        init();
    }, []);

    console.log("Rendered ImageUploader with imageId:", imageId, "errors:", errors)

    return (
        <div>
            <label htmlFor={name} className="label pb-2">{label}</label>

            {
                // render the markId value hidden so the backend can accept it
                markedDeletedId?.map(item =>
                    <input key={item} type="text" name={`${name.endsWith("[]") ? name.slice(0, -2) : name}MarkedId[]`} defaultValue={item} hidden />
                )}

            <input
                value={imageId || ''}
                type="text"
                name={name}
                readOnly
                hidden />
            <input
                type="file"
                ref={fileInputRef}
                onChange={handleFileChange}
                accept="image/*"
                className="hidden"
            />
            {image ? (
                <div className="relative group w-full h-80 rounded-lg overflow-hidden">
                    <img src={image} alt="Upload preview" className="w-full h-full object-cover" />
                    <div className="absolute inset-0 hover:bg-black/20 bg-opacity-0 group-hover:bg-opacity-40 transition-all duration-300 flex items-center justify-center">
                        <button
                            onClick={handleRemoveImage}
                            className="absolute top-3 right-3 bg-white rounded-full p-2 text-gray-700 hover:bg-gray-200 hover:text-black opacity-0 group-hover:opacity-100 transition-opacity duration-300 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-white"
                            aria-label="Remove image"
                            type='button'
                        >
                            <CloseIcon className="w-6 h-6" />
                        </button>
                    </div>

                    {/** Errors */}

                </div>
            ) : (
                <div
                    onClick={handleClick}
                    onDragOver={handleDragOver}
                    onDragLeave={handleDragLeave}
                    onDrop={handleDrop}
                    className={`w-full h-80 rounded-lg border-2 border-dashed flex flex-col items-center justify-center cursor-pointer transition-all duration-300
            ${isDragging ? 'border-indigo-600 bg-indigo-50' : 'border-gray-300 hover:border-indigo-500 hover:bg-gray-50'} ${(errors?.length > 0 ? "border-red-600" : "")}`}
                >
                    <div className="text-center p-8">
                        <UploadIcon className={`w-16 h-16 mx-auto mb-4 transition-colors duration-300 ${isDragging ? 'text-indigo-600' : 'text-gray-400'}`} />
                        <p className="text-lg font-semibold text-gray-700">Drag & drop your image here</p>
                        <p className="text-gray-500 mt-1">or <span className="text-indigo-600 font-medium">click to browse</span></p>
                        <p className="text-xs text-gray-400 mt-4">Supports: JPG, JPEG, PNG, GIF</p>
                    </div>
                </div>
            )}

            {/** errors */}
            {errors?.map((error, index) => (
                <p key={index} className="text-sm text-red-500 mt-1">{error}</p>
            ))}
        </div>

    );
};


export default ImageUploader;
Enter fullscreen mode Exit fullscreen mode

Now that the preparation is ready, we will create a component for creating and updating.

3. Create a Single Form Component.

Now we’ll create the BranchForm. It should accept an optional data parameter, which will contain the values to be updated when editing. Make sure to also include the BranchInstance you created earlier, since it represents the structure of the form data.

export function BranchForm({ data = null
}: { data?: BranchInstance | null }) {  
    return ()
}
Enter fullscreen mode Exit fullscreen mode

Inside BranchForm, create a constant named initial that inherits the properties of BranchSaveResponse. Initialize the fields you need so the form has a default response state to work with

const initial: BranchSaveResponse = {
   status: 0, // initial data
   value: data? data : {
      id: '',
      branch_name: '',
      location: '',
      img: '',
      google_coordinate: ''
   },
   message: "",
   errors: {}
}
Enter fullscreen mode Exit fullscreen mode

Below the initial constant, set up useRouter and useActionState. These will handle the logic for running actions and processing the response returned by useActionState. The states are for pending, failed to save, and save successfully

const router = useRouter();
const [formState, actionState, isPending] = useActionState(saveBranch, initial);
if (isPending) {
   toast.loading(`Saving Branch...`, { id: "toast" })
} else if (formState?.status === 400) {
   toast.error("Failed to save branch", { id: "toast" })
}else if ([200,201].includes(formState?.status)){
   toast.success("Branch saved successfully", { id: "toast" })
   router.push("/admin/branches")
}
Enter fullscreen mode Exit fullscreen mode

Add the form fields and set the form’s action to the useActionState action. The hidden id input is important because it stores the branch ID, if it’s empty, the form is in create mode; if it has a value, it means we’re updating an existing branch.

You’ll also notice that we’re passing both the default values and the form state errors. This ensures the fields always stay updated, and if any errors occur, they’ll be displayed next to their corresponding inputs.

<form action={actionState} className="mt-4 flex flex-col gap-4">
   <input type="hidden" name="id" value={initial.value.id} />
   <InputField2 label="Branch Name" name="branch_name" defaultValue={formState.value.branch_name} errors={formState.errors?.fieldErrors?.branch_name}/>
   <InputField2 label="Location" name="location" defaultValue={formState.value.location} errors={formState.errors?.fieldErrors?.location}/>
   <InputField2 label="Google Coordinate" name="google_coordinate" defaultValue={formState.value.google_coordinate} errors={formState.errors?.fieldErrors?.google_coordinate} />
   <ImageUploader label="Image" name="img" initial={formState.value.img} errors={formState.errors?.fieldErrors?.img} />
   <button type="submit" className="btn btn-primary w-fit mt-4 btn-lg self-end" disabled={isPending}>Submit</button>
</form>
Enter fullscreen mode Exit fullscreen mode

This is the overall code:

export function BranchForm({
    data = null
}: { data?: BranchInstance | null }) {

    const initial: BranchSaveResponse = {
        status: false,
        value: data? data : {
            id: '',
            branch_name: '',
            location: '',
            img: '',
            google_coordinate: ''
        },
        message: "",
        errors: {}
    }

    const router = useRouter();

    const [formState, actionState, isPending] = useActionState(saveBranch, initial);

    if (isPending) {
        toast.loading(`Saving Branch...`, { id: "toast" })
    } else if (formState?.status === false && formState?.message) {
        toast.error("Failed to save branch", { id: "toast" })
    }

    useEffect(() => {

        if (formState?.status === true) {
            toast.success("Branch saved successfully", { id: "toast" })
            router.push("/admin/branches")
        }

    }, [formState?.status, router]);


    return (
        <form action={actionState} className="mt-4 flex flex-col gap-4">
            <input type="hidden" name="id" value={initial.value.id} />

            <InputField2 label="Branch Name" name="branch_name" defaultValue={formState.value.branch_name} errors={formState.errors?.fieldErrors?.branch_name}/>
            <InputField2 label="Location" name="location" defaultValue={formState.value.location} errors={formState.errors?.fieldErrors?.location}/>
            <InputField2 label="Google Coordinate" name="google_coordinate" defaultValue={formState.value.google_coordinate} errors={formState.errors?.fieldErrors?.google_coordinate} />
            <ImageUploader label="Image" name="img" initial={formState.value.img} errors={formState.errors?.fieldErrors?.img} />
            <button type="submit" className="btn btn-primary w-fit mt-4 btn-lg self-end" disabled={isPending}>Submit</button>
        </form>
    );

}
Enter fullscreen mode Exit fullscreen mode

4. Create the action function for creating and editing.

Set up the function returning a promise of BranchSaveRespone.

export async function saveBranch(prevState: BranchSaveResponse, formData: FormData) : Promise<BranchSaveResponse> {
   ...
}
Enter fullscreen mode Exit fullscreen mode

Inside the function, check if the current user has permission to save a branch. If not, throw an error to stop execution and return early. This step is optional.

// implement your own security here, this is just a simple one
const cookie = await verifySessionCookie()
    if(!cookie.valid)
        throw new Error('Invalid Action')
Enter fullscreen mode Exit fullscreen mode

Convert the form data into a JavaScript object. Make sure it matches the BranchInstance structure and also includes any fields with MarkedID.

const entries = formDataToObject(formData) as BranchInstance & {
    imgMarkedId?: string[];
};
Enter fullscreen mode Exit fullscreen mode

Separate the id from the converted form data and validate the remaining data using Zod. You can either create a custom validation function or use the Zod schema for BranchInstance directly. In this example, I created one.

If the validation fails, return a response containing the message, a status of 400, the data, and the validation errors.

// seperate the id and the data
const { id, ...data } = entries

// return validation error if there is an invalid value
const validated_result = zod_validate<BranchInstance>(BranchSchema, data)

// If the validated status results to false, return the errors
if (!validated_result.status)
   return {
      status: 400,
      message: "validation error occured",
      value: entries,
      errors: validated_result.errors
   }
Enter fullscreen mode Exit fullscreen mode

In update mode, which id exist, check if any MarkedIDs exist for images, and delete them if they do.

// only in update mode delays the deletion of image
if (id)
    // delete existing images from appwrite storage
    deleteImageFromField(entries?.imgMarkedId as string | string[])
Enter fullscreen mode Exit fullscreen mode

Save the validated data to Firebase along with its id. If the id is an empty string, the function is in create mode; otherwise, it’s in update mode.

saveDocItem('branches', validated_result.value, id)
Enter fullscreen mode Exit fullscreen mode

Revalidate the page which you will redirect after saving the data

revalidatePath("/admin/branches");
Enter fullscreen mode Exit fullscreen mode

Return status updated as ok if id exists, otherwise 201 for created

return { status: id ? 200 : 201, value: entries, message: "Branch saved successfully" };
}
Enter fullscreen mode Exit fullscreen mode

And finished, a single form that can create and update a branch

5. Form Usage

// create mode
<BranchForm />

// update mode
const data = fetchBranch(id) as BranchInstance
<BranchForm data={data}/>
Enter fullscreen mode Exit fullscreen mode

Top comments (0)