My Approach to Building Forms with useActionState and Server Actions in Next.js 15

A practical pattern for leveraging useActionState and server actions for consistent form handling

·Matija Žiberna·
My Approach to Building Forms with useActionState and Server Actions in Next.js 15

I recently refactored a project management application to use React 19's new useActionState hook, and I wanted to share the approach I settled on for handling CRUD operations with forms. This isn't necessarily the "right" way to do it, but it's a pattern that's been working well for me.

The challenge I was facing was typical: I had forms for creating and editing projects that were cluttered with manual error handling, try/catch blocks, and custom loading state management. With React 19's useActionState hook, I saw an opportunity to clean this up and create a more consistent pattern across all my CRUD operations.

The Core Pattern: Consistent State Objects

The foundation of my approach starts with how I structure server actions. Instead of throwing errors that need to be caught, I return consistent state objects that the form can react to.

// File: src/types/project-action.ts
export interface ProjectActionState {
  success: boolean
  message: string
  type: 'idle' | 'create' | 'update' | 'delete' | 'error'
  data?: {
    projectId?: string
    redirectUrl?: string
  }
  errors?: Record<string, string[]>
}

export const initialProjectActionState: ProjectActionState = {
  success: false,
  message: '',
  type: 'idle'
}

This interface covers everything I need: success/error states, user-friendly messages, operation types for different handling, optional data for navigation, and field-level errors for validation feedback. The key insight here is that every server action returns this same structure, making the client-side handling predictable.

Server Actions That Return State

Here's how I structure my server actions. Instead of throwing errors, they always return a ProjectActionState object:

// File: src/actions/project/createProject.ts
const createProject = async (
  prevState: ProjectActionState,
  formData: FormData
): Promise<ProjectActionState> => {
  try {
    const userId = await getUserFromSession();
    
    const validatedFields = projectFormSchema.safeParse({
      name: formData.get("name"),
      description: formData.get("description"),
      // ... other fields
    });

    if (!validatedFields.success) {
      return {
        success: false,
        message: "Podatki niso veljavni. Preverite vnos in poskusite znova.",
        type: 'error',
        errors: validatedFields.error.flatten().fieldErrors
      };
    }

    const newProject = await prisma.project.create({
      data: {
        // ... project data
      },
    });

    return {
      success: true,
      message: "Projekt je bil uspešno ustvarjen.",
      type: 'create',
      data: { 
        projectId: newProject.id,
        redirectUrl: formData.get("redirectUrl") as string || undefined
      }
    };
  } catch (error: any) {
    return {
      success: false,
      message: error.message || "Pri shranjevanju projekta je prišlo do nepričakovane napake.",
      type: 'error'
    };
  }
};

The pattern is consistent: validate input, perform the operation, return success state with data, or return error state with message. No exceptions thrown, no external error handling needed. The prevState parameter is required by useActionState, and the type field helps me distinguish between different operations in the UI.

The Reusable Form Component

My ProjectForm component handles both create and edit operations by accepting the appropriate server action as a prop:

// File: src/components/projects/project-form.tsx
function ProjectForm({ action, companyId, redirectUrl, projectData, cta = "Kreiraj Projekt", recordingId, onSuccess }: {
  action: (prevState: ProjectActionState, formData: FormData) => Promise<ProjectActionState>,
  companyId: string,
  redirectUrl: string,
  projectData?: Project,
  cta?: string,
  recordingId?: string,
  onSuccess?: (data?: ProjectActionState['data']) => void
}) {
  const [state, formAction, isPending] = useActionState(action, initialProjectActionState);
  const router = useRouter();

  // Handle state changes with useEffect
  useEffect(() => {
    if (state.type === 'create' && state.success) {
      toast.success("Uspešno ustvarjen projekt", {
        description: state.message,
      })
      
      // Call onSuccess callback (for dialog close)
      if (onSuccess) {
        onSuccess(state.data)
      }
      
      // Navigation logic
      if (state.data?.redirectUrl) {
        router.push(state.data.redirectUrl)
      } else if (state.data?.projectId) {
        router.push(`/projects/${state.data.projectId}`)
      }
      router.refresh()
    } else if (state.type === 'update' && state.success) {
      toast.success("Posodobljen projekt", {
        description: state.message,
      })
      router.refresh()
    } else if (state.type === 'error') {
      toast.error("Napaka", {
        description: state.message || "Pri shranjevanju projekta je prišlo do napake.",
      });
    }
  }, [state, router, onSuccess])

  function onSubmit(values: z.infer<typeof projectFormSchema>) {
    const formData = new FormData()
    Object.entries(values).forEach(([key, value]) => {
      if (value !== undefined && value !== null) {
        formData.append(key, value)
      }
    })
    formData.append('companyId', companyId)
    formData.append('redirectUrl', redirectUrl)
    if (projectData?.id) formData.append('projectId', projectData.id)
    if (recordingId) formData.append('recordingId', recordingId)

    // Use formAction from useActionState
    formAction(formData)
  }

  return (
    <Form {...form}>
      <form onSubmit={form.handleSubmit(onSubmit)}>
        {/* Form fields... */}
        <Button 
          disabled={isPending} 
          type="submit"
        >
          {isPending ? "Shranjevanje..." : cta}
        </Button>
      </form>
    </Form>
  )
}

The beauty of this approach is in the useEffect that watches the state changes. Different operation types trigger different behaviors: creates might redirect to the new resource, updates might stay on the same page, and errors always show appropriate messages. The isPending flag from useActionState gives me loading states without any manual management.

Using the Same Component for Create and Edit

The flexibility comes from passing different actions and props to the same component:

// Create page
<ProjectForm 
  action={createProject}
  companyId={currentCompany.id}
  redirectUrl="/projects"
  cta="Ustvari projekt"
/>

// Edit page  
<ProjectForm 
  action={updateProject}
  companyId={currentCompany.id}
  redirectUrl="/projects"
  projectData={project}
  cta="Posodobi projekt"
/>

When projectData is provided, the form pre-fills with existing values. When it's not, the form starts empty. The server actions handle the difference: createProject creates a new record, updateProject modifies an existing one. But the form component doesn't need to know which operation it's performing.

Dialog Integration with Auto-Close

For dialogs, I use the onSuccess callback to handle auto-closing:

// File: src/components/dialogs/add-project-dialog.tsx
function AddProjectDialog({ companyId, redirectPath = "/projects" }) {
    const [open, setOpen] = useState(false)

    const handleSuccess = (data?: ProjectActionState['data']) => {
        setOpen(false) // Auto-close dialog on success
    }

    return (
        <Dialog open={open} onOpenChange={setOpen}>
            <DialogTrigger asChild>
                <Button variant={"outline"}>
                    Dodaj projekt
                </Button>
            </DialogTrigger>
            <DialogContent>
                <ProjectForm 
                    redirectUrl={redirectPath} 
                    action={createProject} 
                    companyId={companyId}
                    onSuccess={handleSuccess}
                />
            </DialogContent>
        </Dialog>
    )
}

The dialog provides a success callback that closes the modal. The form doesn't need to know it's in a dialog context - it just calls the callback when appropriate. This keeps the form component focused and reusable.

Extending to Delete Operations

I apply the same pattern to delete operations, even though they don't use the main form component:

// File: src/components/projects/delete-project-button.tsx
export function DeleteProjectButton({ projectId }: DeleteProjectButtonProps) {
  const [state, deleteAction, isPending] = useActionState(deleteProject, initialProjectActionState);
  const router = useRouter();

  useEffect(() => {
    if (state.type === 'delete' && state.success) {
      toast.success("Projekt je bil uspešno izbrisan", {
        description: state.message,
      });
      router.push("/projects");
      router.refresh();
    } else if (state.type === 'error') {
      toast.error("Napaka pri brisanju projekta", {
        description: state.message,
      });
    }
  }, [state, router]);

  const handleDelete = () => {
    const formData = new FormData();
    formData.append("projectId", projectId);
    deleteAction(formData);
  };

  return (
    <Button 
      onClick={handleDelete} 
      variant="destructive"
      disabled={isPending}
    >
      <AlertTriangle className="w-4 h-4 mr-2" />
      {isPending ? "Brišem..." : "Izbriši projekt"}
    </Button>
  );
}

Even for simple delete operations, I get consistent error handling, loading states, and user feedback. The pattern scales down as well as it scales up.

What I Like About This Approach

This pattern has several advantages that work well for my projects. The consistent state structure means I can predict how every operation will behave. No more scattered try/catch blocks or manual loading state management. The form component is genuinely reusable - I can use it for creates, edits, and even in dialogs without modification.

The server actions are easier to test because they always return predictable objects rather than throwing exceptions. Error handling is centralized in the useEffect, so I have one place to customize how different error types are displayed to users.

Most importantly, it follows React 19's intended patterns. The useActionState hook was designed for exactly this kind of form interaction, and by structuring my server actions to return state objects, I'm working with the framework rather than against it.

This approach has been working well across multiple projects, and I find it strikes a good balance between consistency and flexibility. It's not necessarily the only way to handle CRUD forms with useActionState, but it's a pattern that's served me well.

Let me know in the comments if you have questions about any part of this implementation, and subscribe for more practical development guides.

Thanks, Matija

0

Comments

Enjoyed this article?
Subscribe to my newsletter for more insights and tutorials.
Matija Žiberna
Matija Žiberna
Full-stack developer, co-founder

I'm Matija Žiberna, a self-taught full-stack developer and co-founder passionate about building products, writing clean code, and figuring out how to turn ideas into businesses. I write about web development with Next.js, lessons from entrepreneurship, and the journey of learning by doing. My goal is to provide value through code—whether it's through tools, content, or real-world software.

You might be interested in