@safeform/core
v4.2.0
Published
End-to-end type-safe forms for React
Downloads
83
Maintainers
Readme
@safeform/core
End-to-end type-safe forms for React. Define your schema once — get typed validation on the server, typed state on the client, and zero glue code in between.
npm install @safeform/core @safeform/nextPeer dependencies: React 18+, Zod 3+, react-hook-form 7+
Table of Contents
- Quick Start
- Multi-Step Forms
- Arrays and Nested Objects
- Bringing Your Own UI
- Masked Inputs
- Middleware
- State Reference
- Security
- Framework Adapters
- License
Quick Start
1. Create your base action builders
// lib/actions.ts
import { createAction } from '@safeform/core'
import { getSession } from '@/lib/auth'
export const publicAction = createAction()
export const authedAction = createAction().use(async (ctx) => {
const session = await getSession()
if (!session) throw new Error('Unauthorized')
return { ...ctx, user: session.user }
})2. Define your schema
The schema lives in a schema.ts file colocated with the route — safe to import on both client and server.
// app/api/employees/schema.ts
import { z } from 'zod'
export const upsertEmployeeSchema = z.object({
firstName: z.string().min(1),
lastName: z.string().min(1),
role: z.enum(['Admin', 'Cashier', 'Janitor']),
ssn: z.string().length(9),
})3. Define your action and mount the route handler
// app/api/employees/route.ts
import { createRouteHandler } from '@safeform/next'
import { authedAction } from '@/lib/actions'
import { upsertEmployeeSchema } from './schema'
import { z } from 'zod'
const upsertEmployeeAction = authedAction.create({
schema: upsertEmployeeSchema,
payload: z.object({ employeeId: z.string().cuid().optional() }),
}, async (data, payload, ctx) => {
// data → typed from upsertEmployeeSchema
// payload → typed from payload schema
// ctx → { user: Session['user'] }
const employee = await db.employee.upsert({ ... })
return { success: true as const, data: { employeeId: employee.id } }
})
export type UpsertEmployeeAction = typeof upsertEmployeeAction
export const POST = createRouteHandler(upsertEmployeeAction)4. Use the form on the client
'use client'
import { useForm, FormField, SafeFormContext } from '@safeform/core'
import { upsertEmployeeSchema } from '@/app/api/employees/schema'
import type { UpsertEmployeeAction } from '@/app/api/employees/route'
export function EmployeeForm({ employee }: { employee?: Employee }) {
const { formProps, state, _ctx } = useForm<UpsertEmployeeAction>({
endpoint: '/api/employees',
schema: upsertEmployeeSchema,
payload: { employeeId: employee?.id },
onSuccess: (data) => console.log('saved', data.employeeId),
onError: (error) => console.error(error),
})
return (
<SafeFormContext.Provider value={_ctx}>
<form {...formProps}>
<FormField name="firstName">
{({ value, onChange, onBlur, errors }) => (
<div>
<input value={value} onChange={e => onChange(e.target.value)} onBlur={onBlur} />
{errors?.map(e => <p key={e}>{e}</p>)}
</div>
)}
</FormField>
<button type="submit" disabled={state.isPending}>
{state.isPending ? 'Saving...' : 'Save'}
</button>
</form>
</SafeFormContext.Provider>
)
}Multi-Step Forms
Unnamed — flat merge
import { z } from 'zod'
export const onboardingSchema = z.tuple([
z.object({ firstName: z.string().min(1), lastName: z.string().min(1) }),
z.object({ address: z.string().min(1), city: z.string().min(1) }),
])export const onboardingAction = authedAction.create({
schema: onboardingSchema,
}, async (data, ctx) => {
// data is flattened: { firstName, lastName, address, city }
return { success: true as const }
})Named — namespaced per step
import { createSteps } from '@safeform/core'
export const intakeSchema = createSteps({
vitals: z.object({ heartRate: z.number(), bloodPressure: z.string() }),
personal: z.object({ firstName: z.string().min(1), lastName: z.string().min(1) }),
})export const intakeAction = authedAction.create({
schema: intakeSchema,
}, async (data, ctx) => {
data.vitals.heartRate // number
data.personal.firstName // string
return { success: true as const }
})const { formProps, state, _ctx, step, totalSteps, next, prev, isFirstStep, isLastStep } =
useForm<OnboardingAction>({
endpoint: '/api/onboarding',
schema: onboardingSchema,
})
return (
<SafeFormContext.Provider value={_ctx}>
<form {...formProps}>
{step === 0 && <FormField name="firstName">{...}</FormField>}
{step === 1 && <FormField name="address">{...}</FormField>}
<div>
{!isFirstStep && <button type="button" onClick={prev}>Back</button>}
{!isLastStep && <button type="button" onClick={next}>Next</button>}
{isLastStep && <button type="submit">Submit</button>}
</div>
</form>
</SafeFormContext.Provider>
)next() validates the current step client-side before advancing.
Arrays and Nested Objects
const schema = z.object({
address: z.object({ city: z.string(), zip: z.string() }),
tags: z.array(z.string()),
})
// Nested — dot notation
<FormField name="address.city">
{({ value, onChange }) => <input value={value} onChange={e => onChange(e.target.value)} />}
</FormField>
// Arrays
<FormArray name="tags">
{({ items, append, remove }) => (
<>
{items.map((_, i) => (
<FormField key={i} name={`tags.${i}`}>
{({ value, onChange }) => (
<div>
<input value={value} onChange={e => onChange(e.target.value)} />
<button type="button" onClick={() => remove(i)}>Remove</button>
</div>
)}
</FormField>
))}
<button type="button" onClick={() => append('')}>Add</button>
</>
)}
</FormArray>Bringing Your Own UI
Build reusable field components once. SafeFormContext.Provider goes outside the <form> so every field inside can read from it. Pass _ctx to each field component — TypeScript infers valid name values from the schema automatically.
// components/fields/text-field.tsx
import { FormField } from '@safeform/core'
import type { Action, TypedCtx, FieldName } from '@safeform/core'
interface TextFieldProps<TAction extends Action<any, any, any, any>> {
ctx: TypedCtx<TAction> // binds this field to a specific form's schema
name: FieldName<TAction> // inferred — only valid field names are accepted
label: string
placeholder?: string
}
export function TextField<TAction extends Action<any, any, any, any>>({
ctx: _ctx, // received for type inference; context is provided by the outer Provider
name,
label,
placeholder,
}: TextFieldProps<TAction>) {
return (
<FormField name={name}>
{({ value, onChange, onBlur, errors }) => (
<div>
<label htmlFor={name}>{label}</label>
<input
id={name}
value={value as string}
placeholder={placeholder}
onChange={e => onChange(e.target.value)}
onBlur={onBlur}
/>
{errors?.map(err => <p key={err}>{err}</p>)}
</div>
)}
</FormField>
)
}Spread formProps onto <form> and place the Provider outside it:
const { _ctx, formProps, state } = useForm<UpsertEmployeeAction>({ ... })
<SafeFormContext.Provider value={_ctx}>
<form {...formProps}> {/* spreads onSubmit + noValidate */}
<TextField ctx={_ctx} name="firstName" label="First Name" />
<TextField ctx={_ctx} name="lastName" label="Last Name" />
{/* TypeScript error: name="ssnn" — not a valid field */}
<button type="submit" disabled={state.isPending}>Save</button>
</form>
</SafeFormContext.Provider>Masked Inputs
useMask is a standalone hook that manages mask state and returns props you can spread onto any input — native, shadcn, Radix, or otherwise. No form context required.
Tokens
| Token | Matches |
|-------|---------|
| # | Digit (0–9) |
| $ | Letter (a–z, A–Z) |
| * | Any non-whitespace |
Everything else in the pattern is a literal — auto-inserted and never typed by the user.
Built-in masks
| Name | Pattern |
|------|---------|
| phone | (###) ###-#### |
| ssn | ###-##-#### |
| date | ##/##/#### |
| dateTime | ##/##/#### ##:## |
| time | ##:## |
| creditCard | #### #### #### #### |
| cvv | ### |
| cvv4 | #### |
| ein | ##-####### |
| postalCode | ##### |
| postalCodeFull | #####-#### |
Basic usage
import { useMask } from '@safeform/core'
const { rawValue, ...maskProps } = useMask('phone')
<input {...maskProps} />maskProps includes value (formatted display), onChange, onKeyDown, placeholder, and maxLength — spread it onto the input. Use rawValue when you need just the digits/letters with no literals (e.g. to pass to an action).
// Phone: value = "(555) 123-4567", rawValue = "5551234567"
// SSN: value = "123-45-6789", rawValue = "123456789"
// Date: value = "01/15/1990", rawValue = "01151990"
await myAction({ phone: rawValue })With any UI library
// shadcn
import { Input } from '@/components/ui/input'
const maskProps = useMask('date')
<Input {...maskProps} />
// Radix or anything else
const maskProps = useMask('ssn')
<MyCustomInput {...maskProps} />Custom pattern
const maskProps = useMask('$$-###-$$$$')
<input {...maskProps} />Using with FormField
Call useMask at the component level, then sync rawValue into the form with useEffect. The input gets the mask's display props; FormField provides errors and handles validation.
'use client'
import { useEffect } from 'react'
import { useForm, SafeFormContext, FormField, useMask } from '@safeform/core'
import type { ContactAction } from '@/app/api/contact/route'
import { contactSchema } from '@/app/api/contact/schema'
export function ContactForm() {
const form = useForm<ContactAction>({ endpoint: '/api/contact', schema: contactSchema })
// One useMask call per masked field — at the component level
const phoneMask = useMask('phone')
const dobMask = useMask('date')
// Sync rawValue → form state whenever the mask value changes
useEffect(() => { form._ctx.rhf.setValue('phone', phoneMask.rawValue) }, [phoneMask.rawValue])
useEffect(() => { form._ctx.rhf.setValue('dob', dobMask.rawValue) }, [dobMask.rawValue])
return (
<SafeFormContext.Provider value={form._ctx}>
<form {...form.formProps}>
<FormField name="phone">
{({ errors }) => (
<div>
<label>Phone</label>
<input
value={phoneMask.value}
onChange={phoneMask.onChange}
onKeyDown={phoneMask.onKeyDown}
placeholder={phoneMask.placeholder}
maxLength={phoneMask.maxLength}
/>
{errors?.map(e => <p key={e}>{e}</p>)}
</div>
)}
</FormField>
<FormField name="dob">
{({ errors }) => (
<div>
<label>Date of Birth</label>
<input
value={dobMask.value}
onChange={dobMask.onChange}
onKeyDown={dobMask.onKeyDown}
placeholder={dobMask.placeholder}
maxLength={dobMask.maxLength}
/>
{errors?.map(e => <p key={e}>{e}</p>)}
</div>
)}
</FormField>
<button type="submit" disabled={form.state.isPending}>Submit</button>
</form>
</SafeFormContext.Provider>
)
}The schema on the server should use rawMask so it validates the clean digits and transforms the value — no formatting characters reach your handler:
import { rawMask } from '@safeform/core'
const contactSchema = z.object({
phone: rawMask('phone'), // validates 10 digits, output: "5551234567"
dob: rawMask('date'), // validates 8 digits, output: "01151990"
})Zod validation
Three helpers cover every case:
| Helper | Validates | Output after parse |
|--------|-----------|--------------------|
| MASK_SCHEMAS.phone | fully masked string (555) 123-4567 | same string |
| maskToZod('phone', msg?) | fully masked string | same string |
| rawMask('phone', msg?) | raw or masked — strips literals | slot chars only 5551234567 |
Use rawMask when your action receives rawValue from useMask — it strips any accidental formatting and validates the clean digits/letters.
import { MASK_SCHEMAS, maskToZod, rawMask } from '@safeform/core'
const schema = z.object({
// Validate a masked display value as-is
phone: MASK_SCHEMAS.phone, // must be "(###) ###-####"
dob: maskToZod('date', 'Bad date'), // custom message
// Validate rawValue from useMask — transforms to clean slot chars
ssn: rawMask('ssn'), // "123456789" after parse
zip: rawMask('postalCode', 'Invalid ZIP'),
pin: rawMask('####', 'PIN must be 4 digits'),
})Middleware
export const authedAction = createAction().use(async (ctx) => {
const session = await getSession()
if (!session) throw new Error('Unauthorized')
return { ...ctx, user: session.user }
})
export const adminAction = authedAction.use(async (ctx) => {
if (ctx.user.role !== 'Admin') throw new Error('Forbidden')
return ctx
})Stack as many layers as needed — each layer extends the context type.
State Reference
{
fieldErrors: Record<string, string[]> // per-field errors from server
error: string | null // global error string
data: TData | null // typed server return value
isPending: boolean // fetch in-flight
}Security
The payload option passes non-editable data alongside a form (e.g. facilityId, record IDs). This data is sent from the client — always re-authorize payload values in your handler:
async (data, payload, ctx) => {
const access = await db.facilityUser.findFirst({
where: { facilityId: payload.facilityId, userId: ctx.user.id },
})
if (!access) throw new Error('Forbidden')
}Framework Adapters
| Package | Framework |
|---|---|
| @safeform/next | Next.js App Router |
License
MIT
