···11+# Composable Forms
22+33+Composable form state, validation, and SwiftUI bindings for
44+ComposableArchitecture2 features.
55+66+`ComposableForms` gives a form a small reducer that owns the current value,
77+focused field, validation state, and submission status. It supports synchronous
88+and asynchronous validation, scoped child fields, debounced validation after
99+observed changes, and SwiftUI helpers for rendering fields from a
1010+`StoreOf<Form<Value>>`.
1111+1212+```swift
1313+import ComposableArchitecture2
1414+import ComposableForms
1515+1616+struct ProfileDraft: Equatable {
1717+ var name = ""
1818+ var email = ""
1919+}
2020+2121+enum ProfileField {
2222+ static let name = AnyHashable(\ProfileDraft.name)
2323+ static let email = AnyHashable(\ProfileDraft.email)
2424+}
2525+2626+typealias ProfileForm = Form<ProfileDraft>
2727+```
2828+2929+## Installation
3030+3131+Add the package to your Swift package dependencies:
3232+3333+```swift
3434+.package(url: "https://tangled.org/woody.fm/swift-composable-forms", branch: "main")
3535+```
3636+3737+Then add `ComposableForms` to the targets that need it:
3838+3939+```swift
4040+.target(
4141+ name: "YourFeature",
4242+ dependencies: [
4343+ "ComposableForms",
4444+ ]
4545+)
4646+```
4747+4848+## Defining Fields
4949+5050+Define the fields a form should observe and validate with `Form.Field`.
5151+5252+```swift
5353+let form = ProfileForm {
5454+ Form.Field(\.name) {
5555+ Validation(error: "Name is required") { !$0.isEmpty }
5656+ }
5757+5858+ Form.Field(\.email) {
5959+ Validation(error: "Email is required") { !$0.isEmpty }
6060+ Validation(error: "Email is invalid") { $0.contains("@") }
6161+ }
6262+}
6363+```
6464+6565+Each field is identified by its key path by default. Pass an explicit `id` when
6666+you want stable domain identifiers or need to coordinate with view code.
6767+6868+```swift
6969+Form.Field(id: ProfileField.name, value: \.name) {
7070+ Validation(error: "Name is required") { !$0.isEmpty }
7171+}
7272+```
7373+7474+## Validation
7575+7676+Validations return `ValidationResult.valid` or one or more `ErrorMessage`
7777+values. Boolean convenience initializers cover common required-field checks:
7878+7979+```swift
8080+Validation(error: "Username is required") { !$0.isEmpty }
8181+```
8282+8383+Use an async validation when the field needs to ask another dependency:
8484+8585+```swift
8686+Validation(policy: .formSubmitted) { username async throws in
8787+ try await usernameIsAvailable(username)
8888+ ? .valid
8989+ : .invalid("Username is unavailable")
9090+}
9191+```
9292+9393+Validation policies decide which user actions run a validation:
9494+9595+```swift
9696+ValidationPolicy.fieldSubmitted
9797+ValidationPolicy.focusLoss
9898+ValidationPolicy.formSubmitted
9999+ValidationPolicy.observedChange
100100+ValidationPolicy.userActions
101101+ValidationPolicy.all
102102+```
103103+104104+Observed value changes are debounced through the
105105+`formObservedChangeValidationDelay` dependency, which tests can override with a
106106+`TestClock`.
107107+108108+## State
109109+110110+`Form.State` stores the draft value and derived form state:
111111+112112+```swift
113113+var state = ProfileForm.State(value: ProfileDraft())
114114+115115+state.value.name = "Blob"
116116+state.focusedField = ProfileField.email
117117+state.validation[ProfileField.name] = .valid
118118+```
119119+120120+The state exposes submission and validation helpers that views can use
121121+directly:
122122+123123+```swift
124124+state.canSubmit
125125+state.hasErrors
126126+state.isSubmitting
127127+state.isSubmitted
128128+state.isValidating
129129+state.validationErrors
130130+```
131131+132132+When `Value` is `Equatable`, `Form.State` is also `Equatable`.
133133+134134+## Submission
135135+136136+Send `.submittedForm` to validate all fields and submit the current value when
137137+the form is valid:
138138+139139+```swift
140140+let feature = ProfileForm {
141141+ Form.Field(\.name) {
142142+ Validation(error: "Name is required") { !$0.isEmpty }
143143+ }
144144+}
145145+.onSubmit { value in
146146+ try save(value)
147147+}
148148+```
149149+150150+If any submit-time async validation is still running, the form remains in the
151151+`.submitting` state until those validations settle.
152152+153153+## SwiftUI
154154+155155+Render a form with `ComposableForm` and field bindings:
156156+157157+```swift
158158+ComposableForm(store) {
159159+ FormField(\.name) { $name in
160160+ TextField("Name", text: $name)
161161+ .submitLabel(.next)
162162+ }
163163+164164+ FormField(\.email) { $email in
165165+ TextField("Email", text: $email)
166166+ .submitLabel(.done)
167167+ }
168168+}
169169+```
170170+171171+`FormField` submits its field when the SwiftUI field submits, which lets the
172172+form validate the field and advance focus to the next field.
173173+174174+Use `FormScope` for nested values. Scoped view field IDs match scoped reducer
175175+field IDs.
176176+177177+```swift
178178+FormScope(\.profile) {
179179+ FormField(\.displayName) { $displayName in
180180+ TextField("Display name", text: $displayName)
181181+ }
182182+}
183183+```
184184+185185+## Field Styles
186186+187187+Customize field rendering with `FormFieldStyle`:
188188+189189+```swift
190190+struct InlineValidationStyle: FormFieldStyle {
191191+ func makeBody(configuration: FormFieldStyleConfiguration) -> some View {
192192+ VStack(alignment: .leading, spacing: 4) {
193193+ configuration.content
194194+ FormFieldValidationStatus(validation: configuration.validation)
195195+ }
196196+ }
197197+}
198198+199199+ComposableForm(store) {
200200+ FormField(\.name) { $name in
201201+ TextField("Name", text: $name)
202202+ }
203203+}
204204+.formFieldStyle(InlineValidationStyle())
205205+```
206206+207207+The default style renders the field content followed by validating and error
208208+messages.
209209+210210+## Requirements
211211+212212+This package currently targets Swift 6.0 or later, iOS 17 or later, and macOS
213213+14 or later. The core form reducer is separate from the SwiftUI helpers; view
214214+APIs are compiled only when SwiftUI is available.