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

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