Skip to content

Instantly share code, notes, and snippets.

@DennisKraaijeveld
Last active April 27, 2025 07:49
Show Gist options
  • Save DennisKraaijeveld/cf4d51a2ad4f537344599dd9171f3517 to your computer and use it in GitHub Desktop.
Save DennisKraaijeveld/cf4d51a2ad4f537344599dd9171f3517 to your computer and use it in GitHub Desktop.
/**
* Generic hook for submitting data via fetcher using FormData.
* Relies on React Router 7's type generation for the action's return type.
*
* @template TSchema - The Zod schema type for input data validation and type inference.
* @template TActionReturn - The specific return type of the target route's action function (inferred via RR7 typegen).
* @param _schema - Zod schema (used primarily for type inference of the 'data' in submit).
* @param actionUrl - The target URL for the fetcher submission.
* @param prepareFormData - A function that takes the typesafe data object (z.infer<TSchema>) and returns a FormData instance.
*/
export function useGenericFormDataFetcher<
TSchema extends z.ZodTypeAny,
TActionReturn,
>(
_schema: TSchema, // Parameter used for type inference
actionUrl: string,
prepareFormData: (data: z.infer<TSchema>) => FormData,
) {
const fetcher = useFetcher<TActionReturn>()
// Infer the data type from the schema for the submit function's parameter
type TData = z.infer<TSchema>
return {
...fetcher,
submit: (data: TData) => {
const formData = prepareFormData(data)
void fetcher.submit(formData, {
method: 'POST',
action: actionUrl,
})
},
}
}
// How to use
import { useFetcher } from 'react-router'
import { z } from 'zod'
import { getSession } from '#app/middleware/session.server.ts'
import { getUser } from '#app/utils/auth.server.ts'
import { prisma } from '#app/utils/db.server.ts'
import { type Route } from './+types/all'
// Exporting the types action response. Doing this so its easier to just import the type in components
export type AutomationActionType = Route.ComponentProps['actionData']
export const DeleteAutomationIntent = 'delete'
export const DeleteBulkAutomationIntent = 'delete-bulk'
export const deleteWorkflowSchema = z.discriminatedUnion('intent', [
z.object({
intent: z.literal(DeleteAutomationIntent),
workflowId: z.string().min(1),
}),
z.object({
intent: z.literal(DeleteBulkAutomationIntent),
// Parsing JSON
workflowIds: z
.string()
.min(1)
.transform((val) => JSON.parse(val))
.pipe(z.array(z.string().min(1)).min(1)),
}),
])
export type DeleteAutomationFormData = z.infer<typeof deleteWorkflowSchema>
export async function action({ request, context }: Route.ActionArgs) {
const session = getSession(context)
const [, workspaceId] = await getUser(session)
const formData = await request.formData()
const formEntries = Object.fromEntries(formData.entries())
const result = deleteWorkflowSchema.safeParse(formEntries)
if (!result.success) {
return {
title: 'Error',
description: 'Failed to delete automation. Please try again.',
}
}
switch (result.data.intent) {
case DeleteAutomationIntent:
try {
const workflowId = result.data.workflowId
await prisma.workflow.delete({
where: { id: workflowId, workspaceId: workspaceId },
})
return {
title: 'Success',
description: 'Automation removed successfully.',
type: 'success',
}
} catch (e: unknown) {
return {
title: 'Error',
description: 'Failed to delete automation. Please try again.',
type: 'error',
}
}
case DeleteBulkAutomationIntent:
try {
const workflowIds = result.data.workflowIds
await prisma.workflow.deleteMany({
where: { id: { in: workflowIds }, workspaceId: workspaceId },
})
return {
title: 'Success',
description: 'Automations removed successfully.',
type: 'success',
}
} catch (e: unknown) {
return {
title: 'Error',
description: 'Failed to delete automations. Please try again.',
type: 'error',
}
}
default:
return {
title: 'Error',
description: 'Failed to delete automation. Please try again.',
type: 'error',
}
}
}
// Fetcher in component
import {
deleteWorkflowSchema,
useGenericFormDataFetcher,
type DeleteAutomationFormData,
type AutomationActionType,
DeleteAutomationIntent,
DeleteBulkAutomationIntent,
} from '#app/routes/_dashboard+/automations+/all.tsx'
export function DeleteAutomationsDialog({
workflows,
showTrigger = true,
onSuccess,
...props
}: DeleteAutomationsDialogProps) {
const { onOpenChange } = props
// Passing in the schema, the route and a helper util to append to FormData.
const deleteFetcher = useGenericFormDataFetcher<
typeof deleteWorkflowSchema,
AutomationActionType
>(deleteWorkflowSchema, '/automations/all', prepareDeleteFormData)
function onDelete() {
startDeleteTransition(async () => {
invariant(workflows.length > 0, 'At least one workflow is required')
if (workflows.length === 1) {
const workflow = workflows[0]
invariant(workflow?.id, 'Workflow ID is required')
const data: DeleteAutomationFormData = {
intent: DeleteAutomationIntent,
workflowId: workflow.id,
}
// This is typesafe now
deleteFetcher.submit(data)
} else {
const workflowIds = workflows.map((workflow) => workflow.id)
invariant(
workflowIds.length > 0,
'At least one workflow ID is required',
)
const data: DeleteAutomationFormData = {
intent: DeleteBulkAutomationIntent,
workflowIds,
}
// This is typesafe now
deleteFetcher.submit(data)
}
})
}
useEffect(() => {
// This is typesafe now
if (deleteFetcher.data?.type === 'success') {
toast.success(deleteFetcher.data.title, {
description: deleteFetcher.data.description,
})
} else if (deleteFetcher.data?.type === 'error') {
toast.error(deleteFetcher.data.title, {
description: deleteFetcher.data.description,
})
}
}, [deleteFetcher.data])
return ( <div /> )
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment