@tanstack/form-core 0.0.8 → 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,23 +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
  }
117
- console.log(this)
119
+
120
+ this.#prevState = state
121
+ this.state = state
118
122
  },
119
123
  },
120
124
  )
121
125
 
122
126
  this.state = this.store.state
127
+ this.#prevState = this.state
123
128
  this.update(opts)
124
129
  }
125
130
 
@@ -128,9 +133,22 @@ export class FieldApi<TData, TFormData> {
128
133
  info.instances[this.uid] = this
129
134
 
130
135
  const unsubscribe = this.form.store.subscribe(() => {
131
- 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
+ })
132
148
  })
133
149
 
150
+ this.options.onMount?.(this)
151
+
134
152
  return () => {
135
153
  unsubscribe()
136
154
  delete info.instances[this.uid]
@@ -140,28 +158,11 @@ export class FieldApi<TData, TFormData> {
140
158
  }
141
159
  }
142
160
 
143
- #updateStore = () => {
144
- this.store.batch(() => {
145
- const nextValue = this.getValue()
146
- const nextMeta = this.getMeta()
147
-
148
- if (nextValue !== this.state.value) {
149
- this.store.setState((prev) => ({ ...prev, value: nextValue }))
150
- }
151
-
152
- if (nextMeta !== this.state.meta) {
153
- this.store.setState((prev) => ({ ...prev, meta: nextMeta }))
154
- }
155
- })
156
- }
157
-
158
161
  update = (opts: FieldApiOptions<TData, TFormData>) => {
159
162
  this.options = {
160
- validatePristine: this.form.options.defaultValidatePristine ?? false,
161
- validateOn: this.form.options.defaultValidateOn ?? 'change',
162
- validateAsyncOn: this.form.options.defaultValidateAsyncOn ?? 'blur',
163
- validateAsyncDebounceMs:
164
- 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,
165
166
  ...opts,
166
167
  }
167
168
 
@@ -208,12 +209,12 @@ export class FieldApi<TData, TFormData> {
208
209
  form: this.form,
209
210
  })
210
211
 
211
- validateSync = async (value = this.state.value) => {
212
- 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
213
216
 
214
- if (!validate) {
215
- return
216
- }
217
+ if (!validate) return
217
218
 
218
219
  // Use the validationCount for all field instances to
219
220
  // track freshness of the validation
@@ -250,12 +251,33 @@ export class FieldApi<TData, TFormData> {
250
251
  }))
251
252
  }
252
253
 
253
- validateAsync = async (value = this.state.value) => {
254
- const { validateAsync, validateAsyncDebounceMs } = this.options
255
-
256
- if (!validateAsync) {
257
- return
258
- }
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
259
281
 
260
282
  if (this.state.meta.isValidating !== true)
261
283
  this.setMeta((prev) => ({ ...prev, isValidating: true }))
@@ -274,14 +296,14 @@ export class FieldApi<TData, TFormData> {
274
296
  })
275
297
  }
276
298
 
277
- if (validateAsyncDebounceMs > 0) {
278
- await new Promise((r) => setTimeout(r, validateAsyncDebounceMs))
299
+ if (debounceMs > 0) {
300
+ await new Promise((r) => setTimeout(r, debounceMs))
279
301
  }
280
302
 
281
303
  // Only kick off validation if this validation is the latest attempt
282
304
  if (checkLatest()) {
283
305
  try {
284
- const rawError = await validateAsync(value, this)
306
+ const rawError = await validate(value, this)
285
307
 
286
308
  if (checkLatest()) {
287
309
  const error = normalizeError(rawError)
@@ -309,44 +331,25 @@ export class FieldApi<TData, TFormData> {
309
331
  return this.getInfo().validationPromise
310
332
  }
311
333
 
312
- shouldValidate = (isAsync: boolean, cause?: ValidationCause) => {
313
- const { validateOn, validateAsyncOn } = this.options
314
- const level = getValidationCauseLevel(cause)
315
-
316
- // Must meet *at least* the validation level to validate,
317
- // e.g. if validateOn is 'change' and validateCause is 'blur',
318
- // the field will still validate
319
- return Object.keys(validateCauseLevels).some((d) =>
320
- isAsync
321
- ? validateAsyncOn
322
- : validateOn === d && level >= validateCauseLevels[d],
323
- )
324
- }
325
-
326
- validate = async (
327
- cause?: ValidationCause,
334
+ validate = (
335
+ cause: ValidationCause,
328
336
  value?: TData,
329
- ): Promise<ValidationError> => {
337
+ ): ValidationError | Promise<ValidationError> => {
330
338
  // If the field is pristine and validatePristine is false, do not validate
331
- if (!this.options.validatePristine && !this.state.meta.isTouched) return
339
+ if (!this.state.meta.isTouched) return
332
340
 
333
341
  // Attempt to sync validate first
334
- if (this.shouldValidate(false, cause)) {
335
- this.validateSync(value)
336
- }
342
+ this.validateSync(value, cause)
337
343
 
338
344
  // If there is an error, return it, do not attempt async validation
339
345
  if (this.state.meta.error) {
340
- return this.state.meta.error
346
+ if (!this.options.asyncAlways) {
347
+ return this.state.meta.error
348
+ }
341
349
  }
342
350
 
343
351
  // No error? Attempt async validation
344
- if (this.shouldValidate(true, cause)) {
345
- return this.validateAsync(value)
346
- }
347
-
348
- // If there is no sync error or async validation attempt, there is no error
349
- return undefined
352
+ return this.validateAsync(value, cause)
350
353
  }
351
354
 
352
355
  getChangeProps = <T extends UserChangeProps<any>>(
@@ -360,9 +363,12 @@ export class FieldApi<TData, TFormData> {
360
363
  props.onChange?.(value)
361
364
  },
362
365
  onBlur: (e) => {
366
+ const prevTouched = this.state.meta.isTouched
363
367
  this.setMeta((prev) => ({ ...prev, isTouched: true }))
368
+ if (!prevTouched) {
369
+ this.validate('change')
370
+ }
364
371
  this.validate('blur')
365
- props.onBlur?.(e)
366
372
  },
367
373
  } as ChangeProps<TData> & Omit<T, keyof ChangeProps<TData>>
368
374
  }
@@ -382,16 +388,6 @@ export class FieldApi<TData, TFormData> {
382
388
  }
383
389
  }
384
390
 
385
- const validateCauseLevels = {
386
- change: 0,
387
- blur: 1,
388
- submit: 2,
389
- }
390
-
391
- function getValidationCauseLevel(cause?: ValidationCause) {
392
- return !cause ? 3 : validateCauseLevels[cause]
393
- }
394
-
395
391
  function normalizeError(rawError?: ValidationError) {
396
392
  if (rawError) {
397
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,11 +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
141
- console.log(this.state)
152
+ this.store.state = state
153
+ this.state = state
142
154
  },
143
155
  },
144
156
  )
@@ -163,10 +175,7 @@ export class FormApi<TFormData> {
163
175
  }
164
176
 
165
177
  if (options.defaultValues !== this.options.defaultValues) {
166
- this.store.setState((prev) => ({
167
- ...prev,
168
- values: options.defaultValues as TFormData,
169
- }))
178
+ this.store.setState(() => getDefaultFormState(options.defaultValues!))
170
179
  }
171
180
  })
172
181
 
@@ -176,7 +185,7 @@ export class FormApi<TFormData> {
176
185
  reset = () =>
177
186
  this.store.setState(() => getDefaultFormState(this.options.defaultValues!))
178
187
 
179
- validateAllFields = async () => {
188
+ validateAllFields = async (cause: ValidationCause) => {
180
189
  const fieldValidationPromises: Promise<ValidationError>[] = [] as any
181
190
 
182
191
  this.store.batch(() => {
@@ -188,9 +197,9 @@ export class FormApi<TFormData> {
188
197
  // Mark them as touched
189
198
  instance.setMeta((prev) => ({ ...prev, isTouched: true }))
190
199
  // Validate the field
191
- if (instance.options.validate) {
192
- fieldValidationPromises.push(instance.validate())
193
- }
200
+ fieldValidationPromises.push(
201
+ Promise.resolve().then(() => instance.validate(cause)),
202
+ )
194
203
  }
195
204
  })
196
205
  },
@@ -200,63 +209,7 @@ export class FormApi<TFormData> {
200
209
  return Promise.all(fieldValidationPromises)
201
210
  }
202
211
 
203
- validateForm = async () => {
204
- const { validate } = this.options
205
-
206
- if (!validate) {
207
- return
208
- }
209
-
210
- // Use the formValidationCount for all field instances to
211
- // track freshness of the validation
212
- this.store.setState((prev) => ({
213
- ...prev,
214
- isValidating: true,
215
- formValidationCount: prev.formValidationCount + 1,
216
- }))
217
-
218
- const formValidationCount = this.state.formValidationCount
219
-
220
- const checkLatest = () =>
221
- formValidationCount === this.state.formValidationCount
222
-
223
- if (!this.validationMeta.validationPromise) {
224
- this.validationMeta.validationPromise = new Promise((resolve, reject) => {
225
- this.validationMeta.validationResolve = resolve
226
- this.validationMeta.validationReject = reject
227
- })
228
- }
229
-
230
- const doValidation = async () => {
231
- try {
232
- const error = await validate(this.state.values, this)
233
-
234
- if (checkLatest()) {
235
- this.store.setState((prev) => ({
236
- ...prev,
237
- isValidating: false,
238
- error: error
239
- ? typeof error === 'string'
240
- ? error
241
- : 'Invalid Form Values'
242
- : null,
243
- }))
244
-
245
- this.validationMeta.validationResolve?.(error)
246
- }
247
- } catch (err) {
248
- if (checkLatest()) {
249
- this.validationMeta.validationReject?.(err)
250
- }
251
- } finally {
252
- delete this.validationMeta.validationPromise
253
- }
254
- }
255
-
256
- doValidation()
257
-
258
- return this.validationMeta.validationPromise
259
- }
212
+ // validateForm = async () => {}
260
213
 
261
214
  handleSubmit = async (e: FormSubmitEvent) => {
262
215
  e.preventDefault()
@@ -285,21 +238,21 @@ export class FormApi<TFormData> {
285
238
  }
286
239
 
287
240
  // Validate all fields
288
- await this.validateAllFields()
241
+ await this.validateAllFields('submit')
289
242
 
290
243
  // Fields are invalid, do not submit
291
244
  if (!this.state.isFieldsValid) {
292
245
  done()
293
- this.options.onInvalidSubmit?.(this.state.values, this)
246
+ this.options.onSubmitInvalid?.(this.state.values, this)
294
247
  return
295
248
  }
296
249
 
297
250
  // Run validation for the form
298
- await this.validateForm()
251
+ // await this.validateForm()
299
252
 
300
253
  if (!this.state.isValid) {
301
254
  done()
302
- this.options.onInvalidSubmit?.(this.state.values, this)
255
+ this.options.onSubmitInvalid?.(this.state.values, this)
303
256
  return
304
257
  }
305
258
 
@@ -353,22 +306,22 @@ export class FormApi<TFormData> {
353
306
  updater: Updater<DeepValue<TFormData, TField>>,
354
307
  opts?: { touch?: boolean },
355
308
  ) => {
356
- const touch = opts?.touch ?? true
309
+ const touch = opts?.touch
357
310
 
358
311
  this.store.batch(() => {
359
- this.store.setState((prev) => {
360
- return {
361
- ...prev,
362
- values: setBy(prev.values, field, updater),
363
- }
364
- })
365
-
366
312
  if (touch) {
367
313
  this.setFieldMeta(field, (prev) => ({
368
314
  ...prev,
369
315
  isTouched: true,
370
316
  }))
371
317
  }
318
+
319
+ this.store.setState((prev) => {
320
+ return {
321
+ ...prev,
322
+ values: setBy(prev.values, field, updater),
323
+ }
324
+ })
372
325
  })
373
326
  }
374
327
 
@@ -393,10 +346,6 @@ export class FormApi<TFormData> {
393
346
  this.setFieldValue(
394
347
  field,
395
348
  (prev) => {
396
- // invariant( // TODO: bring in invariant
397
- // Array.isArray(prev),
398
- // `Cannot insert a field value into a non-array field. Check that this field's existing value is an array: ${field}.`
399
- // )
400
349
  return (prev as DeepValue<TFormData, TField>[]).map((d, i) =>
401
350
  i === index ? value : d,
402
351
  ) as any
@@ -413,10 +362,6 @@ export class FormApi<TFormData> {
413
362
  this.setFieldValue(
414
363
  field,
415
364
  (prev) => {
416
- // invariant( // TODO: bring in invariant
417
- // Array.isArray(prev),
418
- // `Cannot insert a field value into a non-array field. Check that this field's existing value is an array: ${field}.`
419
- // )
420
365
  return (prev as DeepValue<TFormData, TField>[]).filter(
421
366
  (_d, i) => i !== index,
422
367
  ) as any