---
title: "My Approach to Building Forms with useActionState and Server Actions in Next.js 15"
slug: "my-approach-crud-forms-react19-useactionstate"
published: "2025-08-22"
updated: "2025-12-05"
validated: "2025-10-20"
categories:
  - "Next.js"
tags:
  - "react 19"
  - "useactionstate"
  - "crud forms"
  - "server actions"
  - "form handling"
  - "react forms"
  - "nextjs forms"
  - "form validation"
  - "state management"
  - "reusable components"
llm-intent: "reference"
audience-level: "intermediate"
framework-versions:
  - "react 19"
  - "next.js"
  - "typescript"
  - "zod"
  - "prisma"
status: "stable"
llm-purpose: "Discover a practical approach to CRUD forms with React 19's useActionState hook. Learn server action patterns, reusable form components."
llm-prereqs:
  - "Access to React 19"
  - "Access to Next.js"
  - "Access to TypeScript"
  - "Access to Zod"
  - "Access to Prisma"
llm-outputs:
  - "Completed outcome: Discover a practical approach to CRUD forms with React 19's useActionState hook. Learn server action patterns, reusable form components."
---

**Summary Triples**
- (Server action, returns, a consistent ProjectActionState object instead of throwing errors)
- (ProjectActionState, contains, success, message, type (idle|create|update|delete|error), optional data, optional field-level errors)
- (initialProjectActionState, defaults, success:false, message:'', type:'idle')
- (Client forms, consume, server actions via React 19's useActionState to track operation state)
- (Server actions, should, map validation failures to errors property (Record<string,string[]>) for field-level UI feedback)
- (Form components, should, use the action type and success flag to drive UI (loading, navigation, toasts, form reset))
- (useActionState, enables, consistent client-side handling of server action lifecycle without try/catch clutter)
- (ProjectActionState.data, may include, projectId and redirectUrl for navigation after success)

### {GOAL}
Discover a practical approach to CRUD forms with React 19's useActionState hook. Learn server action patterns, reusable form components.

### {PREREQS}
- Access to React 19
- Access to Next.js
- Access to TypeScript
- Access to Zod
- Access to Prisma

### {STEPS}
1. Follow the detailed walkthrough in the article content below.

<!-- llm:goal="Discover a practical approach to CRUD forms with React 19's useActionState hook. Learn server action patterns, reusable form components." -->
<!-- llm:prereq="Access to React 19" -->
<!-- llm:prereq="Access to Next.js" -->
<!-- llm:prereq="Access to TypeScript" -->
<!-- llm:prereq="Access to Zod" -->
<!-- llm:prereq="Access to Prisma" -->
<!-- llm:output="Completed outcome: Discover a practical approach to CRUD forms with React 19's useActionState hook. Learn server action patterns, reusable form components." -->

# My Approach to Building Forms with useActionState and Server Actions in Next.js 15
> Discover a practical approach to CRUD forms with React 19's useActionState hook. Learn server action patterns, reusable form components.
Matija Žiberna · 2025-08-22

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.

```typescript
// 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:

```typescript
// 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:

```typescript
// 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:

```typescript
// 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:

```typescript
// 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:

```typescript
// 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

## LLM Response Snippet
```json
{
  "goal": "Discover a practical approach to CRUD forms with React 19's useActionState hook. Learn server action patterns, reusable form components.",
  "responses": [
    {
      "question": "What does the article \"My Approach to Building Forms with useActionState and Server Actions in Next.js 15\" cover?",
      "answer": "Discover a practical approach to CRUD forms with React 19's useActionState hook. Learn server action patterns, reusable form components."
    }
  ]
}
```