Skip to content

Instantly share code, notes, and snippets.

@merthanmerter
Last active April 21, 2025 02:59
Show Gist options
  • Save merthanmerter/beaafd1602873aac5aef457f0796a808 to your computer and use it in GitHub Desktop.
Save merthanmerter/beaafd1602873aac5aef457f0796a808 to your computer and use it in GitHub Desktop.
Next Safe Actions & React Hook Form - Reusable form component
// actions.ts
export const addUpdateProject = privateActionClient
.metadata({ actionName: 'addUpdateProject', description: 'Add or update a project.' })
.schema(projectsInsertSchema)
.action(
async ({ parsedInput, ctx }) => {
const { id, ...rest } = parsedInput
const [{ insertId }] = await ctx.db
.insert(projects)
.values(parsedInput)
.onDuplicateKeyUpdate({ set: rest })
.execute()
return { action: id ? 'update' : 'insert', id: id ?? insertId }
},
{
onSuccess: (args) => redirect(`/projects/${args.data?.id}`),
}
)
// definitions.ts
export const projectsInsertSchema = createInsertSchema(projects)
.extend({
id: z.number().optional(),
name: z.string().min(1, MESSAGES.name_is_required).max(50, MESSAGES.name_max_length),
description: z.string().min(1, MESSAGES.description_is_required).max(100, MESSAGES.description_max_length),
})
.pick({
id: true,
name: true,
description: true,
})
// custom form component
'use client'
import {
Form as Form_,
FormControl as FormControl_,
FormDescription as FormDescription_,
FormField as FormField_,
FormItem as FormItem_,
FormLabel as FormLabel_,
FormMessage as FormMessage_,
} from '@/components/ui/form'
import { FormHTMLAttributes } from 'react'
import { ControllerProps, FieldPath, FieldValues, UseFormReturn } from 'react-hook-form'
import { Button } from '@/components/ui/button'
import { ChevronLeft } from 'lucide-react'
import { usePathname, useRouter } from 'next/navigation'
import React from 'react'
type FormRootProps<T extends FieldValues> = Omit<FormHTMLAttributes<HTMLFormElement>, 'action'> & {
form: UseFormReturn<T>
action?: (data: T) => void | undefined
}
function FormRoot<T extends FieldValues>(props: FormRootProps<T>) {
const { form, action, children, className, ...rest } = props
const handleSubmitAction: () => void = form.handleSubmit((data: T) => {
if (action) {
action(data)
}
})
return (
<Form_<T> {...form}>
<form action={handleSubmitAction} className={className} {...rest}>
{children}
</form>
</Form_>
)
}
type CustomFormFieldProps<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
> = ControllerProps<TFieldValues, TName> & {
label: string
description?: string
}
const FormField = <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
>(
props: CustomFormFieldProps<TFieldValues, TName>
) => {
const { label, description, render, ...rest } = props
return (
<FormField_
{...rest}
render={(field) => (
<FormItem_>
<FormLabel_>{label}</FormLabel_>
<FormControl_>{render(field)}</FormControl_>
{description && <FormDescription_>{description}</FormDescription_>}
<FormMessage_ />
</FormItem_>
)}
/>
)
}
function FormHeader({
title,
removeBackButton = false,
children,
}: {
title?: string
removeBackButton?: boolean
children?: React.ReactNode
}) {
const router = useRouter()
const pathname = usePathname()
return (
<div className='inline-flex gap-2 items-center'>
{!removeBackButton && (
<Button
onClick={() => router.push(pathname?.split('/').slice(0, -1).join('/'))}
type='button'
aria-label='Back'
variant='outline'
size='icon'
className='h-7 w-7'>
<ChevronLeft className='h-4 w-4' />
<span className='sr-only'>Back</span>
</Button>
)}
{title && <legend className='font-bold text-lg'>{title}</legend>}
{children}
</div>
)
}
export const Form = {
Root: FormRoot,
Header: FormHeader,
Field: FormField,
}
export default Form
// usage example
'use client'
import Form from '@/components/composables/form'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { MESSAGES } from '@/constants'
import { zodResolver } from '@hookform/resolvers/zod'
import { useHookFormAction } from '@next-safe-action/adapter-react-hook-form/hooks'
import { InferSafeActionFnResult } from 'next-safe-action/.'
import { toast } from 'sonner'
import { addUpdateProject, getProject } from '../actions'
import { projectsInsertSchema } from '../definitions'
export default function ProjectForm({
initialValues,
}: {
initialValues?: InferSafeActionFnResult<typeof getProject>['data']
}) {
const {
form,
action: { execute, status },
} = useHookFormAction(addUpdateProject, zodResolver(projectsInsertSchema), {
actionProps: {
onSuccess: ({ data }) => {
toast.success(data?.action === 'update' ? MESSAGES.record_updated : MESSAGES.record_created)
},
onError: ({ error }) => {
toast.error(error?.serverError)
},
},
formProps: {
values: { id: initialValues?.id, name: initialValues?.name ?? '', description: initialValues?.description ?? '' },
},
})
return (
<Form.Root
form={form}
action={execute}
className='flex gap-4 flex-col border p-4 rounded-sm shadow-sm bg-background'>
<Form.Header title='Project'>
<Button className='ml-auto' disabled={status === 'executing'} type='submit' size='sm'>
{initialValues?.id ? 'Update' : 'Create'}
</Button>
</Form.Header>
<Form.Field
control={form.control}
name='name'
label='Name'
description='Project name is a unique identifier.'
render={({ field }) => <Input {...field} />}
/>
<Form.Field
control={form.control}
name='description'
label='Description'
description='Provide a brief description of the project.'
render={({ field }) => <Input {...field} />}
/>
</Form.Root>
)
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment