A library for helping build high quality forms in SwiftUI and TCA
0

Configure Feed

Select the types of activity you want to include in your feed.

Add README

authored by

Woodrow Melling and committed by
Tangled
(Jun 16, 2026, 12:48 AM +0300) d337268f 0d7b823e

Waiting for spindle ...
+214
+214
README.md
··· 1 + # Composable Forms 2 + 3 + Composable form state, validation, and SwiftUI bindings for 4 + ComposableArchitecture2 features. 5 + 6 + `ComposableForms` gives a form a small reducer that owns the current value, 7 + focused field, validation state, and submission status. It supports synchronous 8 + and asynchronous validation, scoped child fields, debounced validation after 9 + observed changes, and SwiftUI helpers for rendering fields from a 10 + `StoreOf<Form<Value>>`. 11 + 12 + ```swift 13 + import ComposableArchitecture2 14 + import ComposableForms 15 + 16 + struct ProfileDraft: Equatable { 17 + var name = "" 18 + var email = "" 19 + } 20 + 21 + enum ProfileField { 22 + static let name = AnyHashable(\ProfileDraft.name) 23 + static let email = AnyHashable(\ProfileDraft.email) 24 + } 25 + 26 + typealias ProfileForm = Form<ProfileDraft> 27 + ``` 28 + 29 + ## Installation 30 + 31 + Add the package to your Swift package dependencies: 32 + 33 + ```swift 34 + .package(url: "https://tangled.org/woody.fm/swift-composable-forms", branch: "main") 35 + ``` 36 + 37 + Then add `ComposableForms` to the targets that need it: 38 + 39 + ```swift 40 + .target( 41 + name: "YourFeature", 42 + dependencies: [ 43 + "ComposableForms", 44 + ] 45 + ) 46 + ``` 47 + 48 + ## Defining Fields 49 + 50 + Define the fields a form should observe and validate with `Form.Field`. 51 + 52 + ```swift 53 + let form = ProfileForm { 54 + Form.Field(\.name) { 55 + Validation(error: "Name is required") { !$0.isEmpty } 56 + } 57 + 58 + Form.Field(\.email) { 59 + Validation(error: "Email is required") { !$0.isEmpty } 60 + Validation(error: "Email is invalid") { $0.contains("@") } 61 + } 62 + } 63 + ``` 64 + 65 + Each field is identified by its key path by default. Pass an explicit `id` when 66 + you want stable domain identifiers or need to coordinate with view code. 67 + 68 + ```swift 69 + Form.Field(id: ProfileField.name, value: \.name) { 70 + Validation(error: "Name is required") { !$0.isEmpty } 71 + } 72 + ``` 73 + 74 + ## Validation 75 + 76 + Validations return `ValidationResult.valid` or one or more `ErrorMessage` 77 + values. Boolean convenience initializers cover common required-field checks: 78 + 79 + ```swift 80 + Validation(error: "Username is required") { !$0.isEmpty } 81 + ``` 82 + 83 + Use an async validation when the field needs to ask another dependency: 84 + 85 + ```swift 86 + Validation(policy: .formSubmitted) { username async throws in 87 + try await usernameIsAvailable(username) 88 + ? .valid 89 + : .invalid("Username is unavailable") 90 + } 91 + ``` 92 + 93 + Validation policies decide which user actions run a validation: 94 + 95 + ```swift 96 + ValidationPolicy.fieldSubmitted 97 + ValidationPolicy.focusLoss 98 + ValidationPolicy.formSubmitted 99 + ValidationPolicy.observedChange 100 + ValidationPolicy.userActions 101 + ValidationPolicy.all 102 + ``` 103 + 104 + Observed value changes are debounced through the 105 + `formObservedChangeValidationDelay` dependency, which tests can override with a 106 + `TestClock`. 107 + 108 + ## State 109 + 110 + `Form.State` stores the draft value and derived form state: 111 + 112 + ```swift 113 + var state = ProfileForm.State(value: ProfileDraft()) 114 + 115 + state.value.name = "Blob" 116 + state.focusedField = ProfileField.email 117 + state.validation[ProfileField.name] = .valid 118 + ``` 119 + 120 + The state exposes submission and validation helpers that views can use 121 + directly: 122 + 123 + ```swift 124 + state.canSubmit 125 + state.hasErrors 126 + state.isSubmitting 127 + state.isSubmitted 128 + state.isValidating 129 + state.validationErrors 130 + ``` 131 + 132 + When `Value` is `Equatable`, `Form.State` is also `Equatable`. 133 + 134 + ## Submission 135 + 136 + Send `.submittedForm` to validate all fields and submit the current value when 137 + the form is valid: 138 + 139 + ```swift 140 + let feature = ProfileForm { 141 + Form.Field(\.name) { 142 + Validation(error: "Name is required") { !$0.isEmpty } 143 + } 144 + } 145 + .onSubmit { value in 146 + try save(value) 147 + } 148 + ``` 149 + 150 + If any submit-time async validation is still running, the form remains in the 151 + `.submitting` state until those validations settle. 152 + 153 + ## SwiftUI 154 + 155 + Render a form with `ComposableForm` and field bindings: 156 + 157 + ```swift 158 + ComposableForm(store) { 159 + FormField(\.name) { $name in 160 + TextField("Name", text: $name) 161 + .submitLabel(.next) 162 + } 163 + 164 + FormField(\.email) { $email in 165 + TextField("Email", text: $email) 166 + .submitLabel(.done) 167 + } 168 + } 169 + ``` 170 + 171 + `FormField` submits its field when the SwiftUI field submits, which lets the 172 + form validate the field and advance focus to the next field. 173 + 174 + Use `FormScope` for nested values. Scoped view field IDs match scoped reducer 175 + field IDs. 176 + 177 + ```swift 178 + FormScope(\.profile) { 179 + FormField(\.displayName) { $displayName in 180 + TextField("Display name", text: $displayName) 181 + } 182 + } 183 + ``` 184 + 185 + ## Field Styles 186 + 187 + Customize field rendering with `FormFieldStyle`: 188 + 189 + ```swift 190 + struct InlineValidationStyle: FormFieldStyle { 191 + func makeBody(configuration: FormFieldStyleConfiguration) -> some View { 192 + VStack(alignment: .leading, spacing: 4) { 193 + configuration.content 194 + FormFieldValidationStatus(validation: configuration.validation) 195 + } 196 + } 197 + } 198 + 199 + ComposableForm(store) { 200 + FormField(\.name) { $name in 201 + TextField("Name", text: $name) 202 + } 203 + } 204 + .formFieldStyle(InlineValidationStyle()) 205 + ``` 206 + 207 + The default style renders the field content followed by validating and error 208 + messages. 209 + 210 + ## Requirements 211 + 212 + This package currently targets Swift 6.0 or later, iOS 17 or later, and macOS 213 + 14 or later. The core form reducer is separate from the SwiftUI helpers; view 214 + APIs are compiled only when SwiftUI is available.