next.jsreact-hook-formreact-server-componentsshadcnui

Server action function not being called Nextjs Shadcn React hook form


This is the form i made using Shadcn just like it says in the docs:

/app/admin/products/_components/ProductForm.tsx

"use client";

import { addProduct } from "@/app/admin/_actions/products";
import { zodResolver } from "@hookform/resolvers/zod";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";

import { NewProductSchema } from "@/zod/schemas";

import { formatCurrency } from "@/lib/formatters";

import { Button } from "@/components/ui/button";
import {
  Form,
  FormControl,
  FormDescription,
  FormField,
  FormItem,
  FormLabel,
  FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";

export function ProductForm() {
  const form = useForm<z.infer<typeof NewProductSchema>>({
    resolver: zodResolver(NewProductSchema),
    defaultValues: {
      name: "",
      description: "",
      priceInCents: 200,
      file: undefined,
      image: undefined,
    },
  });

  const fileRef = form.register("file", { required: true });
  const fileRef2 = form.register("image", { required: true });

  const [priceInCents, setPriceInCents] = useState<number>(200);

  async function onSubmit(values: z.infer<typeof NewProductSchema>) {
    console.log(values);
    await addProduct(values);
  }

  return (
    <Form {...form}>
      <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-2">
        <FormField
          control={form.control}
          name="name"
          render={({ field }) => (
            <FormItem>
              <FormLabel>Name</FormLabel>
              <FormControl>
                <Input placeholder="name" {...field} />
              </FormControl>
              <FormMessage />
            </FormItem>
          )}
        />
        //.... more fields

        <Button disabled={form.formState.isSubmitting} type="submit">
          {form.formState.isSubmitting ? "Saving..." : "Save"}
        </Button>
      </form>
    </Form>
  );
}

I tried to trim it down as much as possible. As you can see, this compoentn is a client component becasue of the "use client" at the top. I wrote a separate function that i want to be run on the server since it requires the prisma client and the fs which can only be run on the server:

/app/admin/_actions/products:

"use server";

import fs from "fs/promises";
import { redirect } from "next/navigation";
import { z } from "zod";

import { NewProductSchema } from "@/zod/schemas";

import { prisma } from "@/lib/prismaClient";

export const addProduct = async (values: z.infer<typeof NewProductSchema>) => {
  const result = NewProductSchema.safeParse(values);
  console.log(result);
  if (result.success === false) {
    return result.error.formErrors.fieldErrors;
  }

  const data = result.data;

  fs.mkdir("products", { recursive: true });
  const filePath = `products/${crypto.randomUUID()}-${data.file.name}`;
  await fs.writeFile(filePath, Buffer.from(await data.file.arrayBuffer()));

  fs.mkdir("public/products", { recursive: true });
  const imagePath = `/products/${crypto.randomUUID()}-${data.image.name}`;
  await fs.writeFile(
    `public${imagePath}`,
    Buffer.from(await data.image.arrayBuffer()),
  );

  await prisma.product.create({
    data: {
      name: data.name,
      description: data.description,
      priceInCents: data.priceInCents,
      filePath,
      imagePath,
    },
  });

  redirect("/admin/products");
};

If i remove "use server" from the top, i get errors like fs cannot imported etc meaning that the function cannot be run on the client as i said. My problem is that when i click on the submit button on the form, the onSubmit function does run, it logs the values, but it never runs the addProduct function. Does anyone know why this is happening? I admit i maybe am not that good at programming and i probably wrote something stupid but nextjs is pissing me off.

Edit: Here is the zod schema if it makes a difference:

export const NewProductSchema = z.object({
  name: z.string().min(2).max(50).trim(),
  priceInCents: z.coerce
    .number()
    .min(200, { message: "The minimum price for a product is 2 dolars" })
    .positive({ message: "The price must be a positive number" }),
  description: z.string().min(8, {
    message: "The description must be at least 8 characters long",
  }),

  file: z
    .any()
    .refine((file) => file?.length == 1, "File is required.")
    .refine(
      (file) => file[0]?.type.startsWith("video/"),
      "Must be a png, jpeg or jpg.",
    )
    .refine((file) => file[0]?.size <= 5000000, `Max file size is 5MB.`),

  image: z
    .any()
    .refine((file) => file?.length == 1, "File is required.")
    .refine(
      (file) =>
        file[0]?.type === "image/png" ||
        file[0]?.type === "image/jpeg" ||
        file[0]?.type === "image/jpg",
      "Must be a png, jpeg or jpg.",
    )
    .refine((file) => file[0]?.size <= 5000000, `Max file size is 5MB.`),
});

Here is the link to the github repo if you want to recreate it. go to the admin/products/new route.


Solution

  • Your ServerAction is perfectly fine but you have a problem with zod schema and your form

    explain

    you cannot pass "File Object" to "server action" Directly because "File Object" is Browser APIs and You should get this error

    Uncaught Error: Only plain objects, and a few built-ins, can be passed to Server Actions. Classes or null prototypes are not supported.

    I don't know why it doesn't appear

    so you can

    fix

    ** parse "File Object" to a plain objects **

    lost File payload So in your case addProduct function cant save file so take a look at use both React Hook Form & server action with useFormState

    1. in zod/schemas.ts
    file: z.object({
        name: z.string(),
        size: z.number(),
        type: z.string()
      })
      .refine((file) => file.size <= 10000000 && ["video/mp4","video/mov"].includes(file.type),"file: Only .mp4, .mov files of 10MB or less are accepted."),
      image: z.array(
        z.object({
          name: z.string(),
          size: z.number(),
          type: z.string()
      }))
      .refine((files) => files.every((file) => file.size <= 50000000&& ["image/png","image/jpeg","image/jpg"].includes(file.type)),"Item image: Only .jpeg, .jpg, .png files of 5MB or less are accepted.")
    
    
    1. in app/admin/products/_components/ProductForm.tsx
    <FormField
      control={form.control}
      name="file"
      render={({ field: { value, onChange, ...fieldProps } }) => (
        <FormItem>
          <FormLabel>file</FormLabel>
          <FormControl>
            <Input
              {...fieldProps}
              placeholder="file"
              type="file"
              accept="video/*"
              onChange={(event) =>
                onChange(event.target.files && event.target.files[0])
              }
            />
          </FormControl>
          <FormMessage />
        </FormItem>
      )}
    />
    <FormField
      control={form.control}
      name="image"
      render={({ field: { value, onChange, ...fieldProps } }) => (
        <FormItem>
          <FormLabel>image</FormLabel>
          <FormControl>
            <Input
              {...fieldProps}
              placeholder="images"
              type="file"
              accept=".jpg, .jpeg, .png"
              multiple
              onChange={(e) =>
                onChange([...Array.from(e.target.files ?? [])])
              }
            />
          </FormControl>
          <FormMessage />
        </FormItem>
      )}
    />
    

    use both React Hook Form & server action with useFormState

    1. change formSchema
    const formSchema = z.object({
      name: z.string().min(2).max(50).trim(),
      description: z.string().min(8, {
        message: "The description must be at least 8 characters long",
      }),
      priceInCents: z.coerce
        .number()
        .int()
        .min(200, { message: "The minimum price for a product is 2 dolars" })
        .positive({ message: "The price must be a positive number" }),
    
      file: z.custom<File>((v) => v instanceof File, {
        message: 'File is required',
      }).refine((file) => file.size <= 10000000 && file.type.startsWith("video/"),"file: Only .mp4, .mov files of 10MB or less are accepted."),
    
      image: z.custom<File>((v) => v instanceof File, {
        message: 'Image is required',
      }).refine((file) => file.size <= 50000000&& file.type.startsWith("image/"),"Item image: Only .jpeg, .jpg, .png files of 5MB or less are accepted."),
    
    1. create useFormState and ref for input
    const [state, formAction] = useFormState(yorserveraction, initialState);
    const inputRef = useRef<HTMLInputElement>(null)
    
    1. change form

    <form action={formAction}> instead of <form onSubmit={form.handleSubmit(onSubmit)}>

    1. Handel validate before send server action

    This is the tricky part

    <input ref={inputRef} type="submit" hidden />
    <button onClick=form.handleSubmit(()=>inputRef.current?.click())}>submit</button >
    

    let me explain

    you Mabey ask while just don't use form ref instead of input ref and that because formRef.subnit() reload the page