@playfast/forms
v0.0.5
Published
<div align="center">
Readme
@playfast/forms
Headless form state for reform. Values, validation, field metadata, list operations, submit flow, and correlations with the rest of your app state — rendering nothing.
@playfast/forms owns everything a form needs except its widgets. It has no DOM and no React API — it produces a typed, platform-neutral view that any renderer can map. Pair it with @playfast/forms-react for typed JSX, or consume the view directly from a custom host.
Install
bun add @playfast/forms @playfast/reform effectPeers: effect and @playfast/reform.
Model
Forms follow the same definition / implementation split as the rest of reform:
import { Schema as S } from 'effect'
import { Form } from '@playfast/forms'
import { CurrentUser, Settings } from './sources'
const CheckoutValues = S.Struct({
email: S.String,
items: S.Array(S.Struct({ name: S.String, qty: S.Number })),
payment: S.Union(
S.TaggedStruct('Card', { cardNumber: S.String, cvv: S.String }),
S.TaggedStruct('Paypal', { email: S.String }),
),
})
class CheckoutForm extends Form.make('CheckoutForm', {
schema: CheckoutValues,
inputs: { user: CurrentUser, settings: Settings },
}) {}Form.make creates a reflectable form definition. Form.live provides the runtime behavior:
const CheckoutFormLive = Form.live(CheckoutForm, {
initial: {
email: '',
items: [],
payment: { _tag: 'Card', cardNumber: '', cvv: '' },
},
limit: ({ inputs }) => ({
email: { required: true, disabled: !inputs.settings.emailEditable },
items: { minItems: 1, maxItems: inputs.settings.maxItems },
}),
validate: ({ values }) =>
values.email.includes('@')
? undefined
: Form.error('email', 'Email must contain @'),
submit: ({ decoded, inputs }) => saveCheckout(decoded, inputs.user.id),
})Read the form inside compositions with Form.view(CheckoutForm) — a typed, platform-neutral object:
const form = yield* Form.view(CheckoutForm)
form.field('email').set('[email protected]')
form.array('items').append({ name: 'Milk', qty: 1 })
form.submit()Correlations
inputs connects a form to other reform state or calculations, read through the same tracking mechanism as normal composition reads — so a view that depends on the form also updates when its limitations or validation context change. Use it for permissions, calculated totals, feature flags, remote defaults, or cross-field constraints sourced from outside the form values.
Limitations
limit returns field metadata keyed by form path. The core does not interpret metadata as DOM attributes; it carries constraints to any renderer:
limit: ({ values }) => ({
email: { required: true, maxLength: 120 },
'payment.cardNumber': {
visible: values.payment._tag === 'Card',
meta: { mask: 'card' },
},
})Paths
Path strings are type-checked from the Effect Schema encoded value — invalid paths fail at compile time through the typed helpers:
field('email')·field('items[0].qty')·array('items')·variantValue('payment')
Lists
Array bindings expose stable keys and item operations:
const items = form.array('items')
items.append({ name: '', qty: 1 })
items.move(0, 1)
items.remove(0)Each item is { key, index, value, remove, move }, so UI layers can map rows without owning form state.
Validation and submit
submit() decodes the Effect Schema, then runs custom validation; schema parse issues and custom errors both route into field errors. canSubmit is a derived read from the current decoded state and stored errors — advisory UI state, not a substitute for submit-time validation. validate() runs validation without submitting; reset() restores the initial values.
The reform family
Core @playfast/reform · typed JSX @playfast/forms-react · hosts @playfast/react / @playfast/react-native · testing @playfast/proof
License
MIT
