From 49a4f242a831545bc40b3090bd9843346eaf555c Mon Sep 17 00:00:00 2001 From: Gabriel Date: Mon, 9 Mar 2026 07:15:42 -0300 Subject: [PATCH 1/5] fix: apply biome formatting across all packages --- apps/docs/package.json | 2 +- packages/core/src/use-action-form-core.ts | 256 +++++++++++----------- 2 files changed, 133 insertions(+), 125 deletions(-) diff --git a/apps/docs/package.json b/apps/docs/package.json index 250c25d..e2346a0 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -28,4 +28,4 @@ "tailwindcss": "^3.4.17", "typescript": "^5.7.3" } -} \ No newline at end of file +} diff --git a/packages/core/src/use-action-form-core.ts b/packages/core/src/use-action-form-core.ts index b95c5c5..573f2c4 100644 --- a/packages/core/src/use-action-form-core.ts +++ b/packages/core/src/use-action-form-core.ts @@ -1,8 +1,8 @@ -"use client"; +'use client' -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { type DefaultValues, type FieldPath, type FieldValues, useForm } from "react-hook-form"; -import type { ZodError, ZodSchema } from "zod"; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { type DefaultValues, type FieldPath, type FieldValues, useForm } from 'react-hook-form' +import type { ZodError, ZodSchema } from 'zod' import type { ActionFormState, @@ -14,30 +14,37 @@ import type { SubmitFunction, UseActionFormCoreOptions, UseActionFormCoreReturn, -} from "./core-types"; -import { defaultErrorMapper } from "./core-types"; -import { clearPersistedValues, debounce, loadPersistedValues, savePersistedValues } from "./persist"; -import { hasUseOptimistic, useOptimistic as useOptimisticReact19, useTransition } from "./react-shim"; +} from './core-types' +import { defaultErrorMapper } from './core-types' +import { clearPersistedValues, debounce, loadPersistedValues, savePersistedValues } from './persist' +import { + hasUseOptimistic, + useOptimistic as useOptimisticReact19, + useTransition, +} from './react-shim' // --------------------------------------------------------------------------- // Internal: client-side Zod validation helper // --------------------------------------------------------------------------- -function validateWithSchema(schema: ZodSchema, values: Record): FieldErrorRecord | null { - const result = schema.safeParse(values); - if (result.success) return null; +function validateWithSchema( + schema: ZodSchema, + values: Record, +): FieldErrorRecord | null { + const result = schema.safeParse(values) + if (result.success) return null - const zodError = result.error as ZodError; - const flat = zodError.flatten(); - const errors: FieldErrorRecord = {}; + const zodError = result.error as ZodError + const flat = zodError.flatten() + const errors: FieldErrorRecord = {} for (const [field, messages] of Object.entries(flat.fieldErrors)) { if (messages && messages.length > 0) { - errors[field] = messages as string[]; + errors[field] = messages as string[] } } - return Object.keys(errors).length > 0 ? errors : null; + return Object.keys(errors).length > 0 ? errors : null } // --------------------------------------------------------------------------- @@ -45,8 +52,8 @@ function validateWithSchema(schema: ZodSchema, values: Record): // --------------------------------------------------------------------------- function useOptimisticFallback(initial: T): [T, (value: T) => void] { - const [state, setState] = useState(initial); - return [state, setState]; + const [state, setState] = useState(initial) + return [state, setState] } // --------------------------------------------------------------------------- @@ -79,50 +86,50 @@ export function useActionFormCore< ): UseActionFormCoreReturn { const { defaultValues: optionDefaults, - mode = "onSubmit", + mode = 'onSubmit', persistKey, errorMapper = defaultErrorMapper as ErrorMapper, onSuccess, onError, persistDebounce = 300, schema: optionsSchema, - validationMode = "onSubmit", + validationMode = 'onSubmit', optimisticKey, optimisticData, optimisticInitial, plugins = [], - } = options; + } = options // ----- Resolve Zod schema ----------------------------------------------- - const resolvedSchema = useMemo(() => optionsSchema ?? undefined, [optionsSchema]); + const resolvedSchema = useMemo(() => optionsSchema ?? undefined, [optionsSchema]) // ----- Resolve initial values (persisted > options) ---------------------- // biome-ignore lint/correctness/useExhaustiveDependencies: intentionally run once on mount const resolvedDefaults = useMemo | undefined>(() => { if (persistKey) { - const persisted = loadPersistedValues(persistKey); + const persisted = loadPersistedValues(persistKey) if (persisted) { return { ...(optionDefaults as Record | undefined), ...persisted, - } as DefaultValues; + } as DefaultValues } } - return optionDefaults; - }, []); + return optionDefaults + }, []) // ----- React Hook Form --------------------------------------------------- const form = useForm({ defaultValues: resolvedDefaults, mode, - }); + }) // ----- useTransition (React 18 & 19) ------------------------------------ - const [isTransitioning, startTransition] = useTransition(); + const [isTransitioning, startTransition] = useTransition() // ----- Action state ------------------------------------------------------ @@ -132,110 +139,111 @@ export function useActionFormCore< submitErrors: null, actionResult: null, isPending: false, - }); + }) // ----- Submission history (for DevTools) ---------------------------------- - const submissionHistoryRef = useRef[]>([]); + const submissionHistoryRef = useRef[]>([]) // ----- Optimistic UI (React 19 only) ------------------------------------- - const hasOptimistic = optimisticKey != null && optimisticData != null; + const hasOptimistic = optimisticKey != null && optimisticData != null - const useOptimisticHook = hasUseOptimistic && useOptimisticReact19 ? useOptimisticReact19 : useOptimisticFallback; + const useOptimisticHook = + hasUseOptimistic && useOptimisticReact19 ? useOptimisticReact19 : useOptimisticFallback - const [optimisticState, setOptimistic] = useOptimisticHook(optimisticInitial as TOptimistic); + const [optimisticState, setOptimistic] = useOptimisticHook(optimisticInitial as TOptimistic) - const confirmedOptimisticRef = useRef(optimisticInitial as TOptimistic); + const confirmedOptimisticRef = useRef(optimisticInitial as TOptimistic) const rollbackOptimistic = useCallback(() => { - setOptimistic(confirmedOptimisticRef.current); - }, [setOptimistic]); + setOptimistic(confirmedOptimisticRef.current) + }, [setOptimistic]) // ----- Persistence ------------------------------------------------------- const debouncedSave = useMemo(() => { - if (!persistKey) return null; + if (!persistKey) return null return debounce((values: TFieldValues) => { - savePersistedValues(persistKey, values); - }, persistDebounce); - }, [persistKey, persistDebounce]); + savePersistedValues(persistKey, values) + }, persistDebounce) + }, [persistKey, persistDebounce]) useEffect(() => { - if (!persistKey || !debouncedSave) return; + if (!persistKey || !debouncedSave) return const subscription = form.watch((values) => { - debouncedSave(values as TFieldValues); - }); + debouncedSave(values as TFieldValues) + }) - return () => subscription.unsubscribe(); - }, [persistKey, debouncedSave, form]); + return () => subscription.unsubscribe() + }, [persistKey, debouncedSave, form]) // ----- Client-side Zod validation (onChange / onBlur) --------------------- useEffect(() => { - if (!resolvedSchema || validationMode === "onSubmit") return; + if (!resolvedSchema || validationMode === 'onSubmit') return const subscription = form.watch((values, { name, type }) => { - if (!name) return; + if (!name) return - if (validationMode === "onChange" || type === "blur") { - const fieldResult = resolvedSchema.safeParse(values); + if (validationMode === 'onChange' || type === 'blur') { + const fieldResult = resolvedSchema.safeParse(values) if (fieldResult.success) { - form.clearErrors(name as FieldPath); + form.clearErrors(name as FieldPath) } else { - const zodError = fieldResult.error as ZodError; - const flat = zodError.flatten(); - const fieldErrors = flat.fieldErrors[name]; + const zodError = fieldResult.error as ZodError + const flat = zodError.flatten() + const fieldErrors = flat.fieldErrors[name] if (fieldErrors && fieldErrors.length > 0) { form.setError(name as FieldPath, { - type: "validation", + type: 'validation', message: fieldErrors[0], - }); + }) } else { - form.clearErrors(name as FieldPath); + form.clearErrors(name as FieldPath) } } } - }); + }) - return () => subscription.unsubscribe(); - }, [resolvedSchema, validationMode, form]); + return () => subscription.unsubscribe() + }, [resolvedSchema, validationMode, form]) // ----- Plugin lifecycle: onMount ----------------------------------------- // biome-ignore lint/correctness/useExhaustiveDependencies: plugins identity changes every render; run once on mount useEffect(() => { - const cleanups = plugins.map((p) => p.onMount?.()).filter(Boolean) as (() => void)[]; + const cleanups = plugins.map((p) => p.onMount?.()).filter(Boolean) as (() => void)[] return () => { for (const cleanup of cleanups) { - cleanup(); + cleanup() } - }; - }, []); // eslint-disable-line react-hooks/exhaustive-deps -- run once on mount + } + }, []) // eslint-disable-line react-hooks/exhaustive-deps -- run once on mount // ----- Manual persist / clear -------------------------------------------- const persist = useCallback(() => { - if (!persistKey) return; - savePersistedValues(persistKey, form.getValues()); - }, [persistKey, form]); + if (!persistKey) return + savePersistedValues(persistKey, form.getValues()) + }, [persistKey, form]) const clearPersisted = useCallback(() => { - if (!persistKey) return; - clearPersistedValues(persistKey); - }, [persistKey]); + if (!persistKey) return + clearPersistedValues(persistKey) + }, [persistKey]) // ----- Set a server error on a field ------------------------------------- const setSubmitError = useCallback( (field: keyof TFieldValues & string, message: string) => { - form.setError(field as never, { type: "server", message }); + form.setError(field as never, { type: 'server', message }) }, [form], - ); + ) // ----- Map server errors to RHF ------------------------------------------ @@ -244,40 +252,40 @@ export function useActionFormCore< for (const [field, messages] of Object.entries(errors)) { if (messages && messages.length > 0) { form.setError(field as never, { - type: "server", + type: 'server', message: messages[0], - }); + }) } } }, [form], - ); + ) // ----- Core submit logic ------------------------------------------------- const executeSubmit = useCallback( async (data: TFieldValues) => { // Client-side schema validation (for onSubmit mode) - if (resolvedSchema && validationMode === "onSubmit") { - const clientErrors = validateWithSchema(resolvedSchema, data as Record); + if (resolvedSchema && validationMode === 'onSubmit') { + const clientErrors = validateWithSchema(resolvedSchema, data as Record) if (clientErrors) { - applyServerErrors(clientErrors); + applyServerErrors(clientErrors) setActionState({ isSubmitting: false, isSubmitSuccessful: false, submitErrors: clientErrors, actionResult: null, isPending: false, - }); - return; + }) + return } } // Plugin: onBeforeSubmit for (const plugin of plugins) { if (plugin.onBeforeSubmit) { - const shouldContinue = await plugin.onBeforeSubmit(data); - if (shouldContinue === false) return; + const shouldContinue = await plugin.onBeforeSubmit(data) + if (shouldContinue === false) return } } @@ -286,20 +294,20 @@ export function useActionFormCore< isSubmitting: true, isPending: true, submitErrors: null, - })); + })) // Apply optimistic update before the action runs if (hasOptimistic && optimisticData) { - const optimisticResult = optimisticData(confirmedOptimisticRef.current, data); - setOptimistic(optimisticResult); + const optimisticResult = optimisticData(confirmedOptimisticRef.current, data) + setOptimistic(optimisticResult) } - const startTime = Date.now(); + const startTime = Date.now() try { - const result = await submit(data); + const result = await submit(data) - const fieldErrors = errorMapper(result); + const fieldErrors = errorMapper(result) // Record submission for DevTools const record: SubmissionRecord = { @@ -310,14 +318,14 @@ export function useActionFormCore< error: null, duration: Date.now() - startTime, success: !fieldErrors || Object.keys(fieldErrors).length === 0, - }; - submissionHistoryRef.current = [...submissionHistoryRef.current.slice(-49), record]; + } + submissionHistoryRef.current = [...submissionHistoryRef.current.slice(-49), record] if (fieldErrors && Object.keys(fieldErrors).length > 0) { - applyServerErrors(fieldErrors); + applyServerErrors(fieldErrors) if (hasOptimistic) { - rollbackOptimistic(); + rollbackOptimistic() } setActionState({ @@ -326,22 +334,22 @@ export function useActionFormCore< submitErrors: fieldErrors, actionResult: result, isPending: false, - }); + }) // Plugin: onError for (const plugin of plugins) { - plugin.onError?.(result, data); + plugin.onError?.(result, data) } - onError?.(result); + onError?.(result) } else { // Success – update confirmed optimistic state if (hasOptimistic && optimisticData) { - confirmedOptimisticRef.current = optimisticData(confirmedOptimisticRef.current, data); + confirmedOptimisticRef.current = optimisticData(confirmedOptimisticRef.current, data) } // Clear persisted data - if (persistKey) clearPersistedValues(persistKey); + if (persistKey) clearPersistedValues(persistKey) setActionState({ isSubmitting: false, @@ -349,14 +357,14 @@ export function useActionFormCore< submitErrors: null, actionResult: result, isPending: false, - }); + }) // Plugin: onSuccess for (const plugin of plugins) { - plugin.onSuccess?.(result, data); + plugin.onSuccess?.(result, data) } - onSuccess?.(result); + onSuccess?.(result) } } catch (error) { // Record failed submission for DevTools @@ -368,11 +376,11 @@ export function useActionFormCore< error: error instanceof Error ? error : new Error(String(error)), duration: Date.now() - startTime, success: false, - }; - submissionHistoryRef.current = [...submissionHistoryRef.current.slice(-49), record]; + } + submissionHistoryRef.current = [...submissionHistoryRef.current.slice(-49), record] if (hasOptimistic) { - rollbackOptimistic(); + rollbackOptimistic() } setActionState((prev) => ({ @@ -380,16 +388,16 @@ export function useActionFormCore< isSubmitting: false, isSubmitSuccessful: false, isPending: false, - })); + })) - const wrappedError = error instanceof Error ? error : new Error(String(error)); + const wrappedError = error instanceof Error ? error : new Error(String(error)) // Plugin: onError for (const plugin of plugins) { - plugin.onError?.(wrappedError as TResult & Error, data); + plugin.onError?.(wrappedError as TResult & Error, data) } - onError?.(wrappedError); + onError?.(wrappedError) } }, [ @@ -407,23 +415,23 @@ export function useActionFormCore< rollbackOptimistic, plugins, ], - ); + ) // ----- handleSubmit wrapper ---------------------------------------------- const handleSubmit = useCallback( (onValid?: (data: TFieldValues) => void | Promise) => { return form.handleSubmit(async (data) => { - if (onValid) await onValid(data); + if (onValid) await onValid(data) // @ts-ignore – React 19 supports async transitions; React 18 ignores the promise startTransition(async () => { - await executeSubmit(data); - }); - }); + await executeSubmit(data) + }) + }) }, [form, executeSubmit, startTransition], - ); + ) // ----- Compose return value ---------------------------------------------- @@ -444,30 +452,30 @@ export function useActionFormCore< isPending: isTransitioning || actionState.isPending, }), [form.formState, actionState, isTransitioning], - ); + ) // ----- Compose optimistic return ----------------------------------------- const optimisticReturn = useMemo(() => { - if (!hasOptimistic) return undefined; + if (!hasOptimistic) return undefined return { data: optimisticState, isPending: isTransitioning || actionState.isPending, rollback: rollbackOptimistic, - } as OptimisticState; - }, [hasOptimistic, optimisticState, isTransitioning, actionState.isPending, rollbackOptimistic]); + } as OptimisticState + }, [hasOptimistic, optimisticState, isTransitioning, actionState.isPending, rollbackOptimistic]) // ----- Compose control with DevTools metadata ---------------------------- const enhancedControl = useMemo(() => { const ctrl = form.control as typeof form.control & { - _submissionHistory: SubmissionRecord[]; - _actionFormState: ActionFormState; - }; - ctrl._submissionHistory = submissionHistoryRef.current; - ctrl._actionFormState = actionState; - return ctrl; - }, [form.control, actionState]); + _submissionHistory: SubmissionRecord[] + _actionFormState: ActionFormState + } + ctrl._submissionHistory = submissionHistoryRef.current + ctrl._actionFormState = actionState + return ctrl + }, [form.control, actionState]) return { ...form, @@ -478,5 +486,5 @@ export function useActionFormCore< persist, clearPersistedData: clearPersisted, optimistic: optimisticReturn, - } as UseActionFormCoreReturn; + } as UseActionFormCoreReturn } From dde300aea6cffbe76f306be84b0159e16ed25ea0 Mon Sep 17 00:00:00 2001 From: Gabriel Date: Mon, 9 Mar 2026 08:16:31 -0300 Subject: [PATCH 2/5] feat-docs-core-update --- MESSAGING.md | 102 + README.md | 749 ++++++-- apps/docs/ADVANCED_PATTERNS.md | 1640 +++++++++++++++++ apps/docs/app/api-reference/page.tsx | 59 +- apps/docs/app/devtools/page.tsx | 2 +- .../examples/_components/example-shell.tsx | 50 + apps/docs/app/examples/catalog.ts | 125 ++ .../page.tsx | 40 + .../page.tsx | 41 + apps/docs/app/examples/login/login-form.tsx | 6 +- apps/docs/app/examples/login/page.tsx | 6 +- .../page.tsx | 54 + .../next-optimistic-list-rollback/page.tsx | 25 + .../next-optimistic-profile-update/page.tsx | 46 + .../examples/next-quickstart-login/page.tsx | 39 + .../examples/next-schema-once-signup/page.tsx | 32 + .../next-wizard-onboarding-persist/page.tsx | 37 + .../optimistic/optimistic-todo-form.tsx | 73 +- apps/docs/app/examples/optimistic/page.tsx | 4 +- apps/docs/app/examples/page.tsx | 113 ++ .../standalone-quickstart-login/page.tsx | 56 + .../standalone-rest-error-mapper/page.tsx | 40 + apps/docs/app/examples/validation/page.tsx | 2 +- .../examples/validation/validation-form.tsx | 76 +- apps/docs/app/examples/wizard/page.tsx | 2 +- apps/docs/app/examples/wizard/wizard-form.tsx | 6 +- apps/docs/app/faq/page.tsx | 146 ++ apps/docs/app/layout.tsx | 74 +- apps/docs/app/page.tsx | 790 +++++--- .../app/recipes/custom-error-mapper/page.tsx | 307 +++ .../app/recipes/edit-server-data/page.tsx | 273 +++ apps/docs/app/recipes/field-array/page.tsx | 306 +++ apps/docs/app/recipes/file-upload/page.tsx | 308 ++++ apps/docs/app/recipes/login-form/page.tsx | 240 +++ apps/docs/app/recipes/modal-form/page.tsx | 363 ++++ .../app/recipes/multi-step-wizard/page.tsx | 351 ++++ apps/docs/app/recipes/nested-fields/page.tsx | 339 ++++ apps/docs/app/recipes/optimistic-ui/page.tsx | 281 +++ apps/docs/app/recipes/page.tsx | 253 +++ .../app/recipes/reset-after-success/page.tsx | 301 +++ .../app/recipes/signup-server-errors/page.tsx | 279 +++ .../app/recipes/standalone-fetch/page.tsx | 336 ++++ apps/docs/app/standalone/page.tsx | 2 +- apps/docs/app/submit-lifecycle/page.tsx | 229 +++ apps/docs/app/troubleshooting/page.tsx | 125 ++ apps/docs/app/why/page.tsx | 415 +++++ packages/core/README.md | 12 +- packages/core/package.json | 2 +- .../src/__tests__/client-validation.test.ts | 276 +-- .../core/src/__tests__/optimistic.test.ts | 189 +- .../core/src/__tests__/persistence.test.ts | 144 +- .../__tests__/use-action-form-core.test.ts | 362 ++-- .../src/__tests__/use-action-form.test.ts | 373 ++-- packages/core/src/core-types.ts | 179 +- packages/core/src/types.ts | 168 +- packages/core/src/use-action-form-core.ts | 290 +-- packages/core/src/use-action-form.ts | 322 ++-- packages/devtools/package.json | 2 +- packages/devtools/src/FormDevTool.tsx | 333 ++-- packages/next/README.md | 57 +- packages/next/package.json | 2 +- packages/standalone/README.md | 18 +- packages/standalone/package.json | 2 +- .../src/__tests__/use-action-form.test.ts | 206 +-- 64 files changed, 10082 insertions(+), 1998 deletions(-) create mode 100644 MESSAGING.md create mode 100644 apps/docs/ADVANCED_PATTERNS.md create mode 100644 apps/docs/app/examples/_components/example-shell.tsx create mode 100644 apps/docs/app/examples/catalog.ts create mode 100644 apps/docs/app/examples/cross-adapter-parity-next-vs-standalone/page.tsx create mode 100644 apps/docs/app/examples/devtools-debug-submission-history/page.tsx create mode 100644 apps/docs/app/examples/migration-rhf-manual-to-hookform-action/page.tsx create mode 100644 apps/docs/app/examples/next-optimistic-list-rollback/page.tsx create mode 100644 apps/docs/app/examples/next-optimistic-profile-update/page.tsx create mode 100644 apps/docs/app/examples/next-quickstart-login/page.tsx create mode 100644 apps/docs/app/examples/next-schema-once-signup/page.tsx create mode 100644 apps/docs/app/examples/next-wizard-onboarding-persist/page.tsx create mode 100644 apps/docs/app/examples/page.tsx create mode 100644 apps/docs/app/examples/standalone-quickstart-login/page.tsx create mode 100644 apps/docs/app/examples/standalone-rest-error-mapper/page.tsx create mode 100644 apps/docs/app/faq/page.tsx create mode 100644 apps/docs/app/recipes/custom-error-mapper/page.tsx create mode 100644 apps/docs/app/recipes/edit-server-data/page.tsx create mode 100644 apps/docs/app/recipes/field-array/page.tsx create mode 100644 apps/docs/app/recipes/file-upload/page.tsx create mode 100644 apps/docs/app/recipes/login-form/page.tsx create mode 100644 apps/docs/app/recipes/modal-form/page.tsx create mode 100644 apps/docs/app/recipes/multi-step-wizard/page.tsx create mode 100644 apps/docs/app/recipes/nested-fields/page.tsx create mode 100644 apps/docs/app/recipes/optimistic-ui/page.tsx create mode 100644 apps/docs/app/recipes/page.tsx create mode 100644 apps/docs/app/recipes/reset-after-success/page.tsx create mode 100644 apps/docs/app/recipes/signup-server-errors/page.tsx create mode 100644 apps/docs/app/recipes/standalone-fetch/page.tsx create mode 100644 apps/docs/app/submit-lifecycle/page.tsx create mode 100644 apps/docs/app/troubleshooting/page.tsx create mode 100644 apps/docs/app/why/page.tsx diff --git a/MESSAGING.md b/MESSAGING.md new file mode 100644 index 0000000..14d49b4 --- /dev/null +++ b/MESSAGING.md @@ -0,0 +1,102 @@ +# hookform-action Messaging Pack + +## Core Positioning +- The missing layer between React Hook Form and your server. +- Typed submit flows with Zod mapping, optimistic UI, persistence, and DevTools. +- Keep RHF ergonomics. Remove repeated integration wiring. + +## 1) One-Liners (15) +1. Typed submit flows for React Hook Form. +2. The missing layer between RHF and your server. +3. Write the schema. Write the action. Skip the wiring. +4. React Hook Form meets typed server submits. +5. Keep RHF. Add reliable server submit flows. +6. One hook for validation, pending state, and submit errors. +7. Zod errors mapped to RHF fields automatically. +8. Optimistic UI with rollback, built in. +9. Persistence for multi-step forms, without storage glue code. +10. Server actions without repetitive form plumbing. +11. End-to-end type safety from input to action result. +12. Predictable RHF submit pipelines for production apps. +13. Same API for Next.js and standalone React apps. +14. Less per-form code between data entry and mutation. +15. Form-server integration you configure, not rewrite. + +## 2) Short Descriptions (10) +1. hookform-action bridges React Hook Form and server submits with typed data flow and automatic field error mapping. +2. It removes repetitive submit plumbing: FormData parsing, transition wiring, and server error propagation. +3. Define your Zod schema once and keep types consistent across validation, submission, and response handling. +4. Enable optimistic updates with rollback through options, not custom state machines. +5. Persist multi-step values with debounced sessionStorage restore behavior. +6. Keep the RHF API you already use while adding server-aware pending and result state. +7. Use the same mental model in Next.js Server Actions and standalone React apps. +8. Add DevTools to inspect form state, submit history, and debug actions during development. +9. Built for teams that need predictable server-backed forms without replacing RHF. +10. A practical typed layer for RHF submit flows with Zod and server feedback. + +## 3) Medium Descriptions (5) +1. hookform-action is the integration layer between React Hook Form and your server. It standardizes typed submission, Zod validation mapping, pending state, and field-level server errors in one pipeline. You keep RHF ergonomics and remove repeated boilerplate. +2. RHF, Zod, and Server Actions are strong primitives, but the space between them is where most form bugs appear. hookform-action closes that gap with typed submit flow control, automatic error mapping, optimistic updates, and optional persistence. +3. Use `withZod` as a single source of truth for validation and types. hookform-action carries those types through the submit lifecycle and keeps form state behavior consistent across client and server boundaries. +4. In Next.js, it plugs into Server Actions directly. In standalone React apps, the same API works with a `submit` function, which keeps form architecture consistent across frameworks. +5. The library is designed for production submit behavior: predictable pending state, rollback-safe optimistic UI, debounced persistence, and DevTools visibility. It reduces per-form complexity while keeping behavior explicit. + +## 4) GitHub Repo Description Options (5) +1. Typed submit flows for React Hook Form: Zod mapping, optimistic UI, persistence, and DevTools. +2. The missing layer between RHF and server submits, with typed actions and automatic field error mapping. +3. Connect RHF to Next.js Server Actions or standalone submits with one consistent API. +4. Server-backed React forms without glue code: typed flow, error mapping, optimistic rollback, persistence. +5. Monorepo for hookform-action core, Next.js/standalone adapters, and DevTools. + +## 5) Headline Variations (5) +1. React Hook Form Meets Typed Server Submits +2. Write the Schema. Write the Action. Skip the Wiring. +3. Typed Submit Flows for Real RHF Apps +4. One Hook Between RHF and Server Mutations +5. Keep React Hook Form. Remove Submit Plumbing. + +## 6) Release Announcement Variations (5) +1. `hookform-action v4.0.3` is live: typed RHF submit flows with Zod mapping, optimistic UI, persistence, and DevTools. +2. Released: `hookform-action v4.0.3`. Same RHF-first API, better server submit consistency. +3. `hookform-action v4.0.3` ships typed form-server wiring so you can stop rewriting the same submit glue code. +4. New release: `hookform-action v4.0.3` for predictable RHF submit pipelines across Next.js and standalone React apps. +5. `hookform-action v4.0.3` is out. If you manually wire RHF + Zod + server submits, this package standardizes that flow. + +## 7) "What This Replaces" Messaging (5) +1. Replaces manual `FormData` parsing and hand-written type casting. +2. Replaces custom `fieldErrors -> setError` mapping code per form. +3. Replaces per-form `useTransition` submit pending wiring. +4. Replaces ad-hoc optimistic update and rollback logic. +5. Replaces custom sessionStorage persistence code for wizard flows. + +## X / Twitter Post Templates (5) +1. React Hook Form + server submits usually means repeated glue code. `hookform-action` gives typed submit flows, Zod error mapping, optimistic UI, persistence, and DevTools. `npm i hookform-action` +2. We built `hookform-action` to remove repetitive RHF submit plumbing: FormData parsing, transition wiring, and field error mapping. Keep RHF, standardize the server flow. +3. `hookform-action v4.0.3` is live. Typed RHF submit flows for Next.js Server Actions and standalone React apps. One API, less boilerplate, clearer behavior. +4. If your RHF forms submit to a server, this is the integration layer: typed actions, Zod mapping, pending state, optimistic rollback, and persistence. +5. `withZod` + `useActionForm`: schema once, typed flow through validation, submit, and response handling. That is the core idea behind `hookform-action`. + +## LinkedIn Post Template (1) +Today we are shipping `hookform-action v4.0.3`. + +React Hook Form, Zod, and Server Actions are solid primitives. The repeated work is in the integration layer between them: submit wiring, error mapping, pending state, and recovery behavior. + +`hookform-action` standardizes that layer with typed submit flows, automatic Zod -> RHF field mapping, optimistic UI with rollback, multi-step persistence, and DevTools. + +It works with Next.js Server Actions and also with standalone React apps through the same API shape. + +If your team is rewriting form-server glue code in every feature, this release is for you. + +## Release Notes Snippets (5) +1. This release strengthens the project positioning around typed submit flows for React Hook Form and server integrations. +2. Messaging now emphasizes practical outcomes: less submit boilerplate, predictable pending state, and consistent field error mapping. +3. Documentation copy is now aligned across README, docs home, and package descriptions for clearer onboarding. +4. NPM metadata has been updated to surface Zod mapping, optimistic UI, persistence, and DevTools in a consistent way. +5. A centralized messaging pack has been added to support launch posts, release notes, and changelog summaries. + +## Changelog Highlights (5) +1. Refined core tagline to "typed submit flows for React Hook Form". +2. Updated docs home messaging to match the current product scope and tone. +3. Aligned package descriptions across `hookform-action`, `-standalone`, `-core`, and `-devtools`. +4. Improved README wording to reinforce technical clarity over marketing phrasing. +5. Added reusable copy blocks for GitHub, npm, X/Twitter, LinkedIn, release notes, and changelog highlights. diff --git a/README.md b/README.md index 7fa9292..8b67170 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,25 @@

⚡ hookform-action

-    Seamless integration between React Hook Form and any React framework
-    with Zod validation, automatic type inference, optimistic UI, multi-step persistence, and DevTools. -
- 📚 Explore the Docs »

+ The missing layer between React Hook Form and your server.
+ Typed submit flows, automatic Zod error mapping, optimistic UI with rollback, persistence, and DevTools —
+ for Next.js Server Actions, Vite, Remix, Astro, or any React app. +

+

+ 📚 Documentation +  ·  + Quick Start +  ·  + Choose Your Path +  ·  + Examples +  ·  + FAQ +  ·  + API Reference +  ·  + Packages +

@@ -19,83 +33,211 @@ ## The Problem -Connecting React Hook Form with server-side actions requires tons of boilerplate: manual FormData conversion, error mapping, state management, and type juggling. +React Hook Form handles form state beautifully, but the moment you connect it to a server — a Next.js Server Action, a REST endpoint, a Remix action — you end up writing the same boilerplate every single time: -**hookform-action** solves this in one hook — for **Next.js Server Actions**, **Vite SPAs**, **Remix**, **Astro**, or any React app. +- Manually serialize form values to `FormData` or JSON +- Wire `useTransition` or `useFormState` to track pending state +- Parse Zod errors from the server response and map them back to individual fields +- Handle `prevState` for progressive enhancement +- Roll back UI state on failure -## Packages +That is hundreds of lines of plumbing that has nothing to do with your actual business logic. -| Package | Description | -| --------------------------------------------------- | ------------------------------------ | -| [`hookform-action-core`](packages/core) | Core library (framework-agnostic) | -| [`hookform-action`](packages/next) | Next.js adapter (⭐ main install) | -| [`hookform-action-standalone`](packages/standalone) | Adapter for Vite, Remix, Astro, SPAs | -| [`hookform-action-devtools`](packages/devtools) | Floating debug panel (FormDevTool) | +**hookform-action gives you one typed hook that handles all of it.** -## What's New in v3 +--- -- 🌍 **Framework-Agnostic Core** — Core decoupled from Next.js. Use with any React framework -- 🚀 **Standalone Adapter** — `hookform-action-standalone` for Vite, Remix, Astro, or any React SPA -- 🔍 **DevTools Panel** — `hookform-action-devtools` for real-time form state inspection -- 🧩 **Internal Plugin System** — Lifecycle hooks (`onBeforeSubmit`, `onSuccess`, `onError`, `onMount`) -- 🔄 **Zero Breaking Changes** — Existing `hookform-action` imports work identically +## Why hookform-action? -## Features +| Concern | Without hookform-action | With hookform-action | +| ---------------------- | ------------------------------------------ | ----------------------------------- | +| Type safety | Manual casts from `FormData` | Full inference from your Zod schema | +| Error mapping | Parse JSON → iterate fields → `setError()` | Automatic, zero config | +| Pending state | `useTransition` + manual boolean | `formState.isPending` | +| Optimistic UI | Custom `useOptimistic` wiring | `optimisticKey` + `optimisticData` | +| Client validation | Duplicate schema setup | Auto-detected from `withZod` | +| Multi-step persistence | Roll your own sessionStorage | `persistKey` + `persistDebounce` | +| Debugging | `console.log` everywhere | `` panel | -- 🔒 **Full Type Inference** — Types inferred from your action automatically -- ⚡ **Auto Error Mapping** — Zod `.flatten().fieldErrors` mapped to RHF fields out of the box -- 🚀 **Optimistic UI** — Native `useOptimistic` integration with automatic rollback -- 🔍 **Client-Side Validation** — Real-time Zod validation (`onChange`/`onBlur`/`onSubmit`) -- 💾 **Wizard Persistence** — Multi-step form state saved to sessionStorage with debounce -- 🧩 **Headless `

`** — Optional wrapper providing FormContext to children -- 📦 **Tiny Bundle** — ESM + CJS, tree-shakeable, peer deps only -- 🧪 **81+ Tests** — Vitest + React Testing Library +--- -## Installation +## Before & After -### Next.js (Server Actions) +### Without hookform-action -```bash -npm install hookform-action react-hook-form zod -``` +```tsx +// ❌ Manual wiring — ~60 lines to do what one hook does +"use client"; +import { useForm } from "react-hook-form"; +import { useTransition } from "react"; + +export function LoginForm() { + const [isPending, startTransition] = useTransition(); + const { + register, + handleSubmit, + setError, + formState: { errors }, + } = useForm(); -### Vite / Remix / Astro (Standalone) + const onSubmit = (values) => { + startTransition(async () => { + const formData = new FormData(); + Object.entries(values).forEach(([k, v]) => formData.append(k, String(v))); -```bash -npm install hookform-action-standalone react-hook-form zod + const result = await loginAction(null, formData); + + if (!result.success && result.errors) { + Object.entries(result.errors).forEach(([field, messages]) => { + setError(field, { message: messages[0] }); + }); + } + }); + }; + + return ( + + + {errors.email && {errors.email.message}} + +
+ ); +} ``` -### DevTools (optional) +### With hookform-action -```bash -npm install hookform-action-devtools +```tsx +// ✅ One hook, fully typed, zero boilerplate +"use client"; +import { useActionForm } from "hookform-action"; +import { loginAction } from "./actions"; + +export function LoginForm() { + const { + register, + handleSubmit, + formState: { errors, isPending }, + } = useActionForm(loginAction, { validationMode: "onChange" }); + + return ( +
+ + {errors.email && {errors.email.message}} + +
+ ); +} ``` +--- + +## Packages + +hookform-action is a monorepo. Install the adapter that matches your stack. + +| Package | Install | Description | +| --------------------------------------------------- | ---------------------------------- | --------------------------------------------------------------------- | +| [`hookform-action`](packages/next) | `npm i hookform-action` | **Next.js adapter** — Server Actions, FormData, `prevState` | +| [`hookform-action-standalone`](packages/standalone) | `npm i hookform-action-standalone` | **Standalone adapter** — fetch, axios, Remix, Vite, Astro | +| [`hookform-action-core`](packages/core) | internal | Framework-agnostic core — `useActionFormCore`, `withZod`, persistence | +| [`hookform-action-devtools`](packages/devtools) | `npm i hookform-action-devtools` | Floating debug panel — form state, submission history | + +> **Zod and react-hook-form are peer dependencies.** Install them alongside any adapter: +> +> ```bash +> npm install hookform-action react-hook-form zod +> ``` + +--- + +## Choose Your Path + +Pick the shortest adoption route for your stack: + +| You are using | Install | Start here | +| --- | --- | --- | +| Next.js App Router + Server Actions | `npm i hookform-action react-hook-form zod` | [Quick Start - Next.js](#quick-start--nextjs) | +| Vite / Remix / Astro / SPA | `npm i hookform-action-standalone react-hook-form zod` | [Quick Start - Standalone](#quick-start--standalone-vite-remix-astro) | +| Custom adapter / framework integration | `npm i hookform-action-core react-hook-form zod` | [How It Works](#how-it-works) | + +--- + +## Examples that show real value + +These are the examples that convert fastest for new users: + +- Login / registration with server validation + field error mapping +- Client-side validation with a shared schema (no duplication) +- Optimistic UI with rollback on action failure +- Multi-step wizard with draft persistence + +Live docs pages: + +- https://hookform-action-docs.vercel.app/examples +- https://hookform-action-docs.vercel.app/recipes + +--- + +## What you stop writing + +- ❌ Manual `FormData` → typed object conversion +- ❌ `.flatten().fieldErrors` → `setError()` mapping +- ❌ Duplicate Zod passes for client-side validation +- ❌ `useTransition` / `startTransition` wiring +- ❌ `useOptimistic` setup and rollback logic +- ❌ `sessionStorage` wiring for multi-step wizards + +## What you get + +- ✅ **Full type inference** — types flow from your Zod schema through `withZod` into the hook with no manual generics +- ✅ **Auto error mapping** — server-side Zod errors (`flatten().fieldErrors`) are automatically applied to RHF fields +- ✅ **Client-side validation** — real-time validation using the same schema, with `onChange`, `onBlur`, or `onSubmit` modes +- ✅ **Optimistic UI** — native `useOptimistic` (React 19) with automatic rollback and a React 18 fallback +- ✅ **Wizard persistence** — multi-step form state survives page refreshes via sessionStorage with debounce +- ✅ **Headless `
`** — optional context provider that distributes form state to any child component +- ✅ **DevTools** — floating panel with live state, submission history, and debug actions; zero CSS dependencies +- ✅ **Plugin system** — lifecycle hooks (`onBeforeSubmit`, `onSuccess`, `onError`, `onMount`) for custom integrations +- ✅ **Tiny footprint** — ESM + CJS, tree-shakeable, only peer deps; no runtime bloat +- ✅ **81+ tests** — Vitest + React Testing Library + +--- + ## Quick Start — Next.js -### 1. Create a Server Action +### 1. Install + +```bash +npm install hookform-action react-hook-form zod +``` + +### 2. Create a Server Action ```ts -// app/actions.ts +// app/login/actions.ts "use server"; import { z } from "zod"; import { withZod } from "hookform-action-core/with-zod"; const schema = z.object({ - email: z.string().email(), - password: z.string().min(8), + email: z.string().email("Invalid email"), + password: z.string().min(8, "At least 8 characters"), }); export const loginAction = withZod(schema, async (data) => { - // data is typed as { email: string; password: string } + // `data` is fully typed as { email: string; password: string } + const user = await db.authenticate(data.email, data.password); + if (!user) return { success: false, errors: { email: ["Invalid credentials"] } }; return { success: true }; }); ``` -### 2. Use the Hook +> `withZod` validates on the server, maps Zod errors to the flat `{ errors: Record }` shape, and attaches the schema to the action so the hook can auto-detect it for client-side validation. + +### 3. Use the Hook ```tsx -// app/login-form.tsx +// app/login/login-form.tsx "use client"; import { useActionForm } from "hookform-action"; import { loginAction } from "./actions"; @@ -104,45 +246,59 @@ export function LoginForm() { const { register, handleSubmit, - formState: { errors, isPending }, + formState: { errors, isPending, isSubmitSuccessful }, } = useActionForm(loginAction, { defaultValues: { email: "", password: "" }, - validationMode: "onChange", + validationMode: "onChange", // schema auto-detected from withZod + onSuccess: () => redirect("/dashboard"), }); return ( - - {errors.email && {errors.email.message}} - - - {errors.password && {errors.password.message}} - - +
+ + {errors.email &&

{errors.email.message}

} +
+ +
+ + {errors.password &&

{errors.password.message}

} +
+ + ); } ``` -## Quick Start — Standalone (Vite, Remix, etc.) +--- + +## Quick Start — Standalone (Vite, Remix, Astro) + +```bash +npm install hookform-action-standalone react-hook-form zod +``` ```tsx +// components/contact-form.tsx import { useActionForm } from "hookform-action-standalone"; import { z } from "zod"; const schema = z.object({ email: z.string().email(), - password: z.string().min(8), + message: z.string().min(10), }); -export function LoginForm() { +export function ContactForm() { const { register, handleSubmit, - formState: { errors, isPending }, + formState: { errors, isPending, isSubmitSuccessful }, } = useActionForm({ submit: async (data) => { - const res = await fetch("/api/login", { + const res = await fetch("/api/contact", { method: "POST", body: JSON.stringify(data), headers: { "Content-Type": "application/json" }, @@ -150,211 +306,446 @@ export function LoginForm() { return res.json(); }, schema, - validationMode: "onChange", - defaultValues: { email: "", password: "" }, + validationMode: "onBlur", + defaultValues: { email: "", message: "" }, }); + if (isSubmitSuccessful) return

Message sent!

; + return (
- - {errors.email && {errors.email.message}} + + {errors.email &&

{errors.email.message}

} - - {errors.password && {errors.password.message}} +