@tanstack/form-core 0.0.9 → 0.0.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/FieldApi.ts CHANGED
@@ -5,22 +5,30 @@ import { Store } from '@tanstack/store'
5
5
 
6
6
  export type ValidationCause = 'change' | 'blur' | 'submit'
7
7
 
8
+ type ValidateFn<TData, TFormData> = (
9
+ value: TData,
10
+ fieldApi: FieldApi<TData, TFormData>,
11
+ ) => ValidationError
12
+
13
+ type ValidateAsyncFn<TData, TFormData> = (
14
+ value: TData,
15
+ fieldApi: FieldApi<TData, TFormData>,
16
+ ) => ValidationError | Promise<ValidationError>
17
+
8
18
  export interface FieldOptions<TData, TFormData> {
9
19
  name: unknown extends TFormData ? string : DeepKeys<TFormData>
10
20
  index?: TData extends any[] ? number : never
11
21
  defaultValue?: TData
12
- validate?: (
13
- value: TData,
14
- fieldApi: FieldApi<TData, TFormData>,
15
- ) => ValidationError
16
- validateAsync?: (
17
- value: TData,
18
- fieldApi: FieldApi<TData, TFormData>,
19
- ) => ValidationError | Promise<ValidationError>
20
- validatePristine?: boolean // Default: false
21
- validateOn?: ValidationCause // Default: 'change'
22
- validateAsyncOn?: ValidationCause // Default: 'blur'
23
- validateAsyncDebounceMs?: number
22
+ asyncDebounceMs?: number
23
+ asyncAlways?: boolean
24
+ onMount?: (formApi: FieldApi<TData, TFormData>) => void
25
+ onChange?: ValidateFn<TData, TFormData>
26
+ onChangeAsync?: ValidateAsyncFn<TData, TFormData>
27
+ onChangeAsyncDebounceMs?: number
28
+ onBlur?: ValidateFn<TData, TFormData>
29
+ onBlurAsync?: ValidateAsyncFn<TData, TFormData>
30
+ onBlurAsyncDebounceMs?: number
31
+ onSubmitAsync?: ValidateAsyncFn<TData, TFormData>
24
32
  defaultMeta?: Partial<FieldMeta>
25
33
  }
26
34
 
@@ -50,7 +58,7 @@ export type UserInputProps = {
50
58
 
51
59
  export type ChangeProps<TData> = {
52
60
  value: TData
53
- onChange: (updater: Updater<TData>) => void
61
+ onChange: (value: TData) => void
54
62
  onBlur: (event: any) => void
55
63
  }
56
64
 
@@ -73,13 +81,8 @@ export class FieldApi<TData, TFormData> {
73
81
  name!: DeepKeys<TFormData>
74
82
  store!: Store<FieldState<TData>>
75
83
  state!: FieldState<TData>
76
- options: RequiredByKey<
77
- FieldOptions<TData, TFormData>,
78
- | 'validatePristine'
79
- | 'validateOn'
80
- | 'validateAsyncOn'
81
- | 'validateAsyncDebounceMs'
82
- > = {} as any
84
+ #prevState!: FieldState<TData>
85
+ options: FieldOptions<TData, TFormData> = {} as any
83
86
 
84
87
  constructor(opts: FieldApiOptions<TData, TFormData>) {
85
88
  this.form = opts.form
@@ -103,22 +106,25 @@ export class FieldApi<TData, TFormData> {
103
106
  },
104
107
  },
105
108
  {
106
- onUpdate: (next) => {
107
- next.meta.touchedError = next.meta.isTouched
108
- ? next.meta.error
109
+ onUpdate: () => {
110
+ const state = this.store.state
111
+
112
+ state.meta.touchedError = state.meta.isTouched
113
+ ? state.meta.error
109
114
  : undefined
110
115
 
111
- // Do not validate pristine fields
112
- const prevState = this.state
113
- this.state = next
114
- if (next.value !== prevState.value) {
115
- this.validate('change', next.value)
116
+ if (state.value !== this.#prevState.value) {
117
+ this.validate('change', state.value)
116
118
  }
119
+
120
+ this.#prevState = state
121
+ this.state = state
117
122
  },
118
123
  },
119
124
  )
120
125
 
121
126
  this.state = this.store.state
127
+ this.#prevState = this.state
122
128
  this.update(opts)
123
129
  }
124
130
 
@@ -127,9 +133,22 @@ export class FieldApi<TData, TFormData> {
127
133
  info.instances[this.uid] = this
128
134
 
129
135
  const unsubscribe = this.form.store.subscribe(() => {
130
- this.#updateStore()
136
+ this.store.batch(() => {
137
+ const nextValue = this.getValue()
138
+ const nextMeta = this.getMeta()
139
+
140
+ if (nextValue !== this.state.value) {
141
+ this.store.setState((prev) => ({ ...prev, value: nextValue }))
142
+ }
143
+
144
+ if (nextMeta !== this.state.meta) {
145
+ this.store.setState((prev) => ({ ...prev, meta: nextMeta }))
146
+ }
147
+ })
131
148
  })
132
149
 
150
+ this.options.onMount?.(this)
151
+
133
152
  return () => {
134
153
  unsubscribe()
135
154
  delete info.instances[this.uid]
@@ -139,28 +158,11 @@ export class FieldApi<TData, TFormData> {
139
158
  }
140
159
  }
141
160
 
142
- #updateStore = () => {
143
- this.store.batch(() => {
144
- const nextValue = this.getValue()
145
- const nextMeta = this.getMeta()
146
-
147
- if (nextValue !== this.state.value) {
148
- this.store.setState((prev) => ({ ...prev, value: nextValue }))
149
- }
150
-
151
- if (nextMeta !== this.state.meta) {
152
- this.store.setState((prev) => ({ ...prev, meta: nextMeta }))
153
- }
154
- })
155
- }
156
-
157
161
  update = (opts: FieldApiOptions<TData, TFormData>) => {
158
162
  this.options = {
159
- validatePristine: this.form.options.defaultValidatePristine ?? false,
160
- validateOn: this.form.options.defaultValidateOn ?? 'change',
161
- validateAsyncOn: this.form.options.defaultValidateAsyncOn ?? 'blur',
162
- validateAsyncDebounceMs:
163
- this.form.options.defaultValidateAsyncDebounceMs ?? 0,
163
+ asyncDebounceMs: this.form.options.asyncDebounceMs ?? 0,
164
+ onChangeAsyncDebounceMs: this.form.options.onChangeAsyncDebounceMs ?? 0,
165
+ onBlurAsyncDebounceMs: this.form.options.onBlurAsyncDebounceMs ?? 0,
164
166
  ...opts,
165
167
  }
166
168
 
@@ -207,12 +209,12 @@ export class FieldApi<TData, TFormData> {
207
209
  form: this.form,
208
210
  })
209
211
 
210
- validateSync = async (value = this.state.value) => {
211
- const { validate } = this.options
212
+ validateSync = async (value = this.state.value, cause: ValidationCause) => {
213
+ const { onChange, onBlur } = this.options
214
+ const validate =
215
+ cause === 'submit' ? undefined : cause === 'change' ? onChange : onBlur
212
216
 
213
- if (!validate) {
214
- return
215
- }
217
+ if (!validate) return
216
218
 
217
219
  // Use the validationCount for all field instances to
218
220
  // track freshness of the validation
@@ -249,12 +251,33 @@ export class FieldApi<TData, TFormData> {
249
251
  }))
250
252
  }
251
253
 
252
- validateAsync = async (value = this.state.value) => {
253
- const { validateAsync, validateAsyncDebounceMs } = this.options
254
-
255
- if (!validateAsync) {
256
- return
257
- }
254
+ validateAsync = async (value = this.state.value, cause: ValidationCause) => {
255
+ const {
256
+ onChangeAsync,
257
+ onBlurAsync,
258
+ onSubmitAsync,
259
+ asyncDebounceMs,
260
+ onBlurAsyncDebounceMs,
261
+ onChangeAsyncDebounceMs,
262
+ } = this.options
263
+
264
+ const validate =
265
+ cause === 'change'
266
+ ? onChangeAsync
267
+ : cause === 'submit'
268
+ ? onSubmitAsync
269
+ : onBlurAsync
270
+
271
+ if (!validate) return
272
+
273
+ const debounceMs =
274
+ cause === 'submit'
275
+ ? 0
276
+ : (cause === 'change'
277
+ ? onChangeAsyncDebounceMs
278
+ : onBlurAsyncDebounceMs) ??
279
+ asyncDebounceMs ??
280
+ 500
258
281
 
259
282
  if (this.state.meta.isValidating !== true)
260
283
  this.setMeta((prev) => ({ ...prev, isValidating: true }))
@@ -273,14 +296,14 @@ export class FieldApi<TData, TFormData> {
273
296
  })
274
297
  }
275
298
 
276
- if (validateAsyncDebounceMs > 0) {
277
- await new Promise((r) => setTimeout(r, validateAsyncDebounceMs))
299
+ if (debounceMs > 0) {
300
+ await new Promise((r) => setTimeout(r, debounceMs))
278
301
  }
279
302
 
280
303
  // Only kick off validation if this validation is the latest attempt
281
304
  if (checkLatest()) {
282
305
  try {
283
- const rawError = await validateAsync(value, this)
306
+ const rawError = await validate(value, this)
284
307
 
285
308
  if (checkLatest()) {
286
309
  const error = normalizeError(rawError)
@@ -308,44 +331,25 @@ export class FieldApi<TData, TFormData> {
308
331
  return this.getInfo().validationPromise
309
332
  }
310
333
 
311
- shouldValidate = (isAsync: boolean, cause?: ValidationCause) => {
312
- const { validateOn, validateAsyncOn } = this.options
313
- const level = getValidationCauseLevel(cause)
314
-
315
- // Must meet *at least* the validation level to validate,
316
- // e.g. if validateOn is 'change' and validateCause is 'blur',
317
- // the field will still validate
318
- return Object.keys(validateCauseLevels).some((d) =>
319
- isAsync
320
- ? validateAsyncOn
321
- : validateOn === d && level >= validateCauseLevels[d],
322
- )
323
- }
324
-
325
- validate = async (
326
- cause?: ValidationCause,
334
+ validate = (
335
+ cause: ValidationCause,
327
336
  value?: TData,
328
- ): Promise<ValidationError> => {
337
+ ): ValidationError | Promise<ValidationError> => {
329
338
  // If the field is pristine and validatePristine is false, do not validate
330
- if (!this.options.validatePristine && !this.state.meta.isTouched) return
339
+ if (!this.state.meta.isTouched) return
331
340
 
332
341
  // Attempt to sync validate first
333
- if (this.shouldValidate(false, cause)) {
334
- this.validateSync(value)
335
- }
342
+ this.validateSync(value, cause)
336
343
 
337
344
  // If there is an error, return it, do not attempt async validation
338
345
  if (this.state.meta.error) {
339
- return this.state.meta.error
346
+ if (!this.options.asyncAlways) {
347
+ return this.state.meta.error
348
+ }
340
349
  }
341
350
 
342
351
  // No error? Attempt async validation
343
- if (this.shouldValidate(true, cause)) {
344
- return this.validateAsync(value)
345
- }
346
-
347
- // If there is no sync error or async validation attempt, there is no error
348
- return undefined
352
+ return this.validateAsync(value, cause)
349
353
  }
350
354
 
351
355
  getChangeProps = <T extends UserChangeProps<any>>(
@@ -359,9 +363,12 @@ export class FieldApi<TData, TFormData> {
359
363
  props.onChange?.(value)
360
364
  },
361
365
  onBlur: (e) => {
366
+ const prevTouched = this.state.meta.isTouched
362
367
  this.setMeta((prev) => ({ ...prev, isTouched: true }))
368
+ if (!prevTouched) {
369
+ this.validate('change')
370
+ }
363
371
  this.validate('blur')
364
- props.onBlur?.(e)
365
372
  },
366
373
  } as ChangeProps<TData> & Omit<T, keyof ChangeProps<TData>>
367
374
  }
@@ -381,16 +388,6 @@ export class FieldApi<TData, TFormData> {
381
388
  }
382
389
  }
383
390
 
384
- const validateCauseLevels = {
385
- change: 0,
386
- blur: 1,
387
- submit: 2,
388
- }
389
-
390
- function getValidationCauseLevel(cause?: ValidationCause) {
391
- return !cause ? 3 : validateCauseLevels[cause]
392
- }
393
-
394
391
  function normalizeError(rawError?: ValidationError) {
395
392
  if (rawError) {
396
393
  if (typeof rawError !== 'string') {
package/src/FormApi.ts CHANGED
@@ -17,13 +17,27 @@ export type FormSubmitEvent = Register extends {
17
17
  export type FormOptions<TData> = {
18
18
  defaultValues?: TData
19
19
  defaultState?: Partial<FormState<TData>>
20
- onSubmit?: (values: TData, formApi: FormApi<TData>) => void
21
- onInvalidSubmit?: (values: TData, formApi: FormApi<TData>) => void
22
- validate?: (values: TData, formApi: FormApi<TData>) => Promise<any>
23
- defaultValidatePristine?: boolean
24
- defaultValidateOn?: ValidationCause
25
- defaultValidateAsyncOn?: ValidationCause
26
- defaultValidateAsyncDebounceMs?: number
20
+ asyncDebounceMs?: number
21
+ onMount?: (values: TData, formApi: FormApi<TData>) => ValidationError
22
+ onMountAsync?: (
23
+ values: TData,
24
+ formApi: FormApi<TData>,
25
+ ) => ValidationError | Promise<ValidationError>
26
+ onMountAsyncDebounceMs?: number
27
+ onChange?: (values: TData, formApi: FormApi<TData>) => ValidationError
28
+ onChangeAsync?: (
29
+ values: TData,
30
+ formApi: FormApi<TData>,
31
+ ) => ValidationError | Promise<ValidationError>
32
+ onChangeAsyncDebounceMs?: number
33
+ onBlur?: (values: TData, formApi: FormApi<TData>) => ValidationError
34
+ onBlurAsync?: (
35
+ values: TData,
36
+ formApi: FormApi<TData>,
37
+ ) => ValidationError | Promise<ValidationError>
38
+ onBlurAsyncDebounceMs?: number
39
+ onSubmit?: (values: TData, formApi: FormApi<TData>) => any | Promise<any>
40
+ onSubmitInvalid?: (values: TData, formApi: FormApi<TData>) => void
27
41
  }
28
42
 
29
43
  export type FieldInfo<TFormData> = {
@@ -99,12 +113,13 @@ export class FormApi<TFormData> {
99
113
  getDefaultFormState({
100
114
  ...opts?.defaultState,
101
115
  values: opts?.defaultValues ?? opts?.defaultState?.values,
102
- isFormValid: !opts?.validate,
116
+ isFormValid: true,
103
117
  }),
104
118
  {
105
- onUpdate: (next) => {
119
+ onUpdate: () => {
120
+ let { state } = this.store
106
121
  // Computed state
107
- const fieldMetaValues = Object.values(next.fieldMeta) as (
122
+ const fieldMetaValues = Object.values(state.fieldMeta) as (
108
123
  | FieldMeta
109
124
  | undefined
110
125
  )[]
@@ -117,15 +132,15 @@ export class FormApi<TFormData> {
117
132
 
118
133
  const isTouched = fieldMetaValues.some((field) => field?.isTouched)
119
134
 
120
- const isValidating = isFieldsValidating || next.isFormValidating
121
- const isFormValid = !next.formError
135
+ const isValidating = isFieldsValidating || state.isFormValidating
136
+ const isFormValid = !state.formError
122
137
  const isValid = isFieldsValid && isFormValid
123
138
  const canSubmit =
124
- (next.submissionAttempts === 0 && !isTouched) ||
125
- (!isValidating && !next.isSubmitting && isValid)
139
+ (state.submissionAttempts === 0 && !isTouched) ||
140
+ (!isValidating && !state.isSubmitting && isValid)
126
141
 
127
- next = {
128
- ...next,
142
+ state = {
143
+ ...state,
129
144
  isFieldsValidating,
130
145
  isFieldsValid,
131
146
  isFormValid,
@@ -134,10 +149,8 @@ export class FormApi<TFormData> {
134
149
  isTouched,
135
150
  }
136
151
 
137
- // Create a shortcut for the state
138
- // Write it back to the store
139
- this.store.state = next
140
- this.state = next
152
+ this.store.state = state
153
+ this.state = state
141
154
  },
142
155
  },
143
156
  )
@@ -162,10 +175,7 @@ export class FormApi<TFormData> {
162
175
  }
163
176
 
164
177
  if (options.defaultValues !== this.options.defaultValues) {
165
- this.store.setState((prev) => ({
166
- ...prev,
167
- values: options.defaultValues as TFormData,
168
- }))
178
+ this.store.setState(() => getDefaultFormState(options.defaultValues!))
169
179
  }
170
180
  })
171
181
 
@@ -175,7 +185,7 @@ export class FormApi<TFormData> {
175
185
  reset = () =>
176
186
  this.store.setState(() => getDefaultFormState(this.options.defaultValues!))
177
187
 
178
- validateAllFields = async () => {
188
+ validateAllFields = async (cause: ValidationCause) => {
179
189
  const fieldValidationPromises: Promise<ValidationError>[] = [] as any
180
190
 
181
191
  this.store.batch(() => {
@@ -187,9 +197,9 @@ export class FormApi<TFormData> {
187
197
  // Mark them as touched
188
198
  instance.setMeta((prev) => ({ ...prev, isTouched: true }))
189
199
  // Validate the field
190
- if (instance.options.validate) {
191
- fieldValidationPromises.push(instance.validate())
192
- }
200
+ fieldValidationPromises.push(
201
+ Promise.resolve().then(() => instance.validate(cause)),
202
+ )
193
203
  }
194
204
  })
195
205
  },
@@ -199,63 +209,7 @@ export class FormApi<TFormData> {
199
209
  return Promise.all(fieldValidationPromises)
200
210
  }
201
211
 
202
- validateForm = async () => {
203
- const { validate } = this.options
204
-
205
- if (!validate) {
206
- return
207
- }
208
-
209
- // Use the formValidationCount for all field instances to
210
- // track freshness of the validation
211
- this.store.setState((prev) => ({
212
- ...prev,
213
- isValidating: true,
214
- formValidationCount: prev.formValidationCount + 1,
215
- }))
216
-
217
- const formValidationCount = this.state.formValidationCount
218
-
219
- const checkLatest = () =>
220
- formValidationCount === this.state.formValidationCount
221
-
222
- if (!this.validationMeta.validationPromise) {
223
- this.validationMeta.validationPromise = new Promise((resolve, reject) => {
224
- this.validationMeta.validationResolve = resolve
225
- this.validationMeta.validationReject = reject
226
- })
227
- }
228
-
229
- const doValidation = async () => {
230
- try {
231
- const error = await validate(this.state.values, this)
232
-
233
- if (checkLatest()) {
234
- this.store.setState((prev) => ({
235
- ...prev,
236
- isValidating: false,
237
- error: error
238
- ? typeof error === 'string'
239
- ? error
240
- : 'Invalid Form Values'
241
- : null,
242
- }))
243
-
244
- this.validationMeta.validationResolve?.(error)
245
- }
246
- } catch (err) {
247
- if (checkLatest()) {
248
- this.validationMeta.validationReject?.(err)
249
- }
250
- } finally {
251
- delete this.validationMeta.validationPromise
252
- }
253
- }
254
-
255
- doValidation()
256
-
257
- return this.validationMeta.validationPromise
258
- }
212
+ // validateForm = async () => {}
259
213
 
260
214
  handleSubmit = async (e: FormSubmitEvent) => {
261
215
  e.preventDefault()
@@ -284,21 +238,21 @@ export class FormApi<TFormData> {
284
238
  }
285
239
 
286
240
  // Validate all fields
287
- await this.validateAllFields()
241
+ await this.validateAllFields('submit')
288
242
 
289
243
  // Fields are invalid, do not submit
290
244
  if (!this.state.isFieldsValid) {
291
245
  done()
292
- this.options.onInvalidSubmit?.(this.state.values, this)
246
+ this.options.onSubmitInvalid?.(this.state.values, this)
293
247
  return
294
248
  }
295
249
 
296
250
  // Run validation for the form
297
- await this.validateForm()
251
+ // await this.validateForm()
298
252
 
299
253
  if (!this.state.isValid) {
300
254
  done()
301
- this.options.onInvalidSubmit?.(this.state.values, this)
255
+ this.options.onSubmitInvalid?.(this.state.values, this)
302
256
  return
303
257
  }
304
258
 
@@ -352,22 +306,22 @@ export class FormApi<TFormData> {
352
306
  updater: Updater<DeepValue<TFormData, TField>>,
353
307
  opts?: { touch?: boolean },
354
308
  ) => {
355
- const touch = opts?.touch ?? true
309
+ const touch = opts?.touch
356
310
 
357
311
  this.store.batch(() => {
358
- this.store.setState((prev) => {
359
- return {
360
- ...prev,
361
- values: setBy(prev.values, field, updater),
362
- }
363
- })
364
-
365
312
  if (touch) {
366
313
  this.setFieldMeta(field, (prev) => ({
367
314
  ...prev,
368
315
  isTouched: true,
369
316
  }))
370
317
  }
318
+
319
+ this.store.setState((prev) => {
320
+ return {
321
+ ...prev,
322
+ values: setBy(prev.values, field, updater),
323
+ }
324
+ })
371
325
  })
372
326
  }
373
327
 
@@ -392,10 +346,6 @@ export class FormApi<TFormData> {
392
346
  this.setFieldValue(
393
347
  field,
394
348
  (prev) => {
395
- // invariant( // TODO: bring in invariant
396
- // Array.isArray(prev),
397
- // `Cannot insert a field value into a non-array field. Check that this field's existing value is an array: ${field}.`
398
- // )
399
349
  return (prev as DeepValue<TFormData, TField>[]).map((d, i) =>
400
350
  i === index ? value : d,
401
351
  ) as any
@@ -412,10 +362,6 @@ export class FormApi<TFormData> {
412
362
  this.setFieldValue(
413
363
  field,
414
364
  (prev) => {
415
- // invariant( // TODO: bring in invariant
416
- // Array.isArray(prev),
417
- // `Cannot insert a field value into a non-array field. Check that this field's existing value is an array: ${field}.`
418
- // )
419
365
  return (prev as DeepValue<TFormData, TField>[]).filter(
420
366
  (_d, i) => i !== index,
421
367
  ) as any