Server Actions and the experimental useFormStatus hook

June 20233 min read

Enter any fake email schema.

Today, we experiment with the newly released Server Actions and the experimental_useFormStatus hook.

Because our Form is a Client Component, we'll need to move the action into a separate actions.ts file with the "use server" directive. This is totally fine and I might use it even in Server Components to keep make it easier to switch a file from RSC to RCC and to separate the concerns.

For testing purposes, we set a timeout of 2000ms to display a loading state and simulate a database connection.

// actions.ts
"use server";
import { redirect } from "next/navigation";
 
export async function submitEmail(data: FormData) {
  const email = data.get("email");
  if (email) {
    // connect to database and store email
    redirect(`/?form=success&email=${email}`);
  }
  return;
}

Let's have a closer look to the form component. You will quickly notice that we are using a Client Component with the main purpose of detecting and mutating the incoming searchParams. All the non-rendering part is optional but makes the form feel interactive.

You might have noticed that we redirect the form action to /?form=success&email=${email} which allows us to create a toast to notify the user on the form status, which in our case always succeeded. Once done, we are reseting the search parameters. In case the user accidentally refreshes the page, we won't show him again the toast notification.

// form.tsx
"use client";
 
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useToast } from "@/components/ui/use-toast";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { submitEmail } from "./actions";
import { SubmitButton } from "./submit-button";
import { useCallback, useEffect } from "react";
 
export function Form() {
  const { toast } = useToast();
  const searchParams = useSearchParams();
  const pathname = usePathname();
  const router = useRouter();
 
  const resetSearchParams = useCallback(() => {
    const params = new URLSearchParams(searchParams.toString());
    console.log({ params });
    params.delete("email");
    params.delete("form");
    router.replace(`${pathname}?${params.toString()}`);
  }, [searchParams, router, pathname]);
 
  useEffect(() => {
    const form = searchParams.has("form") && searchParams.get("form");
    const email = searchParams.has("email") && searchParams.get("email");
 
    if (form === "success" && email) {
      toast({
        title: "Joined Waitlist",
        description: `Added ${email} to the fake waitlist.`,
      });
      resetSearchParams();
    }
  }, [toast, searchParams, resetSearchParams]);
 
  return (
    <form
      action={submitEmail}
      className="not-prose grid w-full max-w-sm gap-1.5"
    >
      <div className="grid w-full gap-1.5">
        <div className="flex w-full items-end gap-1.5">
          <div className="grid w-full gap-1.5">
            <Label htmlFor="email">Email</Label>
            <Input
              required
              name="email"
              id="email"
              type="email"
              placeholder="me@domain.com"
            />
          </div>
          <SubmitButton />
        </div>
        <p className="text-muted-foreground text-xs">
          Enter any fake email schema.
        </p>
      </div>
    </form>
  );
}

Now you might wonder why we need a separate SubmitButton. This is because the current useFormStatus seems to not work in the same file as the Form. As simple as that. And I couldn't figure out the specific reason, as both components are Client Components.

// submit-button.tsx
"use client";
 
import { Button } from "@/components/ui/button";
import { experimental_useFormStatus as useFormStatus } from "react-dom";
import { LoadingAnimation } from "./loading-animation";
 
export function SubmitButton() {
  const { pending } = useFormStatus();
  return (
    <Button
      type="submit"
      disabled={pending}
      className="w-20 disabled:opacity-100"
    >
      {pending ? <LoadingAnimation /> : "Join"}
    </Button>
  );
}

The loading animation is inspired by the vercel's one. It's build with tailwind-animate which is added to ui.shadcn by default. Otherwise we could easily extend the tailwind.config.js with utilities. Let me know if you have any questions. I won't go deeper into it!

The source code is available on GitHub.