Skip to content

Commit bfe231a

Browse files
feat(form-core): field meta isDefaultValue (#1456)
1 parent fc1faa4 commit bfe231a

File tree

10 files changed

+303
-31
lines changed

10 files changed

+303
-31
lines changed

docs/assets/field-states-extended.png

113 KB
Loading

docs/assets/field-states.png

-98.5 KB
Loading

docs/framework/react/guides/basic-concepts.md

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -92,17 +92,40 @@ const {
9292
} = field.state
9393
```
9494

95-
There are three field states that can be useful to see how the user interacts with a field: A field is _"touched"_ when the user clicks/tabs into it, _"pristine"_ until the user changes value in it, and _"dirty"_ after the value has been changed. You can check these states via the `isTouched`, `isPristine` and `isDirty` flags, as seen below.
95+
There are three states in the metadata that can be useful to see how the user interacts with a field:
96+
97+
- _"isTouched"_, after the user clicks/tabs into the field
98+
- _"isPristine"_, until the user changes the field value
99+
- _"isDirty"_, after the fields value has been changed
96100

97101
```tsx
98102
const { isTouched, isPristine, isDirty } = field.state.meta
99103
```
100104

101105
![Field states](https://raw.githubusercontent.com/TanStack/form/main/docs/assets/field-states.png)
102106

103-
> **Important note for users coming from `React Hook Form`**: the `isDirty` flag in `TanStack/form` is different from the flag with the same name in RHF.
104-
> In RHF, `isDirty = true`, when the form's values are different from the original values. If the user changes the values in a form, and then changes them again to end up with values that match the form's default values, `isDirty` will be `false` in RHF, but `true` in `TanStack/form`.
105-
> The default values are exposed both on the form's and the field's level in `TanStack/form` (`form.options.defaultValues`, `field.options.defaultValue`), so you can write your own `isDefaultValue()` helper if you need to emulate RHF's behavior.`
107+
## Understanding 'isDirty' in Different Libraries
108+
109+
Non-Persistent `dirty` state
110+
111+
- **Libraries**: React Hook Form (RHF), Formik, Final Form.
112+
- **Behavior**: A field is 'dirty' if its value differs from the default. Reverting to the default value makes it 'clean' again.
113+
114+
Persistent `dirty` state
115+
116+
- **Libraries**: Angular Form, Vue FormKit.
117+
- **Behavior**: A field remains 'dirty' once changed, even if reverted to the default value.
118+
119+
We have chosen the persistent 'dirty' state model. To also support a non-persistent 'dirty' state, we introduce the isDefault flag. This flag acts as an inverse of the non-persistent 'dirty' state.
120+
121+
```tsx
122+
const { isTouched, isPristine, isDirty, isDefaultValue } = field.state.meta
123+
124+
// The following line will re-create the non-Persistent `dirty` functionality.
125+
const nonPersistentIsDirty = !isDefaultValue
126+
```
127+
128+
![Field states extended](https://raw.githubusercontent.com/TanStack/form/main/docs/assets/field-states-extended.png)
106129

107130
## Field API
108131

packages/form-core/src/FieldApi.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -656,6 +656,10 @@ export type FieldMetaDerived<
656656
* A boolean indicating if the field is valid. Evaluates `true` if there are no field errors.
657657
*/
658658
isValid: boolean
659+
/**
660+
* A flag indicating whether the field's current value is the default value
661+
*/
662+
isDefaultValue: boolean
659663
}
660664

661665
export type AnyFieldMetaDerived = FieldMetaDerived<

packages/form-core/src/FormApi.ts

Lines changed: 37 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,14 @@ import { Derived, Store, batch } from '@tanstack/store'
22
import {
33
deleteBy,
44
determineFormLevelErrorSourceAndValue,
5+
evaluate,
56
functionalUpdate,
67
getAsyncValidatorArray,
78
getBy,
89
getSyncValidatorArray,
910
isGlobalFormValidationError,
1011
isNonEmptyArray,
1112
setBy,
12-
shallow,
1313
} from './utils'
1414

1515
import {
@@ -626,6 +626,10 @@ export type DerivedFormState<
626626
* A boolean indicating if none of the form's fields' values have been modified by the user. Evaluates `true` if the user have not modified any of the fields. Opposite of `isDirty`.
627627
*/
628628
isPristine: boolean
629+
/**
630+
* A boolean indicating if all of the form's fields are the same as default values.
631+
*/
632+
isDefaultValue: boolean
629633
/**
630634
* A boolean indicating if the form and all its fields are valid. Evaluates `true` if there are no errors.
631635
*/
@@ -883,21 +887,26 @@ export class FormApi<
883887
for (const fieldName of Object.keys(
884888
currBaseStore.fieldMetaBase,
885889
) as Array<keyof typeof currBaseStore.fieldMetaBase>) {
886-
const currBaseVal = currBaseStore.fieldMetaBase[
890+
const currBaseMeta = currBaseStore.fieldMetaBase[
887891
fieldName as never
888892
] as AnyFieldMetaBase
889893

890-
const prevBaseVal = prevBaseStore?.fieldMetaBase[
894+
const prevBaseMeta = prevBaseStore?.fieldMetaBase[
891895
fieldName as never
892896
] as AnyFieldMetaBase | undefined
893897

894898
const prevFieldInfo =
895899
prevVal?.[fieldName as never as keyof typeof prevVal]
896900

901+
const curFieldVal = getBy(currBaseStore.values, fieldName)
902+
897903
let fieldErrors = prevFieldInfo?.errors
898-
if (!prevBaseVal || currBaseVal.errorMap !== prevBaseVal.errorMap) {
904+
if (
905+
!prevBaseMeta ||
906+
currBaseMeta.errorMap !== prevBaseMeta.errorMap
907+
) {
899908
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
900-
fieldErrors = Object.values(currBaseVal.errorMap ?? {}).filter(
909+
fieldErrors = Object.values(currBaseMeta.errorMap ?? {}).filter(
901910
(val) => val !== undefined,
902911
) as never
903912

@@ -912,26 +921,38 @@ export class FormApi<
912921
}
913922

914923
// As primitives, we don't need to aggressively persist the same referential value for performance reasons
915-
const isFieldPristine = !currBaseVal.isDirty
916924
const isFieldValid = !isNonEmptyArray(fieldErrors ?? [])
925+
const isFieldPristine = !currBaseMeta.isDirty
926+
const isDefaultValue =
927+
evaluate(
928+
curFieldVal,
929+
getBy(this.options.defaultValues, fieldName),
930+
) ||
931+
evaluate(
932+
curFieldVal,
933+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
934+
this.getFieldInfo(fieldName)?.instance?.options.defaultValue,
935+
)
917936

918937
if (
919938
prevFieldInfo &&
920939
prevFieldInfo.isPristine === isFieldPristine &&
921940
prevFieldInfo.isValid === isFieldValid &&
941+
prevFieldInfo.isDefaultValue === isDefaultValue &&
922942
prevFieldInfo.errors === fieldErrors &&
923-
currBaseVal === prevBaseVal
943+
currBaseMeta === prevBaseMeta
924944
) {
925945
fieldMeta[fieldName] = prevFieldInfo
926946
originalMetaCount++
927947
continue
928948
}
929949

930950
fieldMeta[fieldName] = {
931-
...currBaseVal,
951+
...currBaseMeta,
932952
errors: fieldErrors,
933953
isPristine: isFieldPristine,
934954
isValid: isFieldValid,
955+
isDefaultValue: isDefaultValue,
935956
} as AnyFieldMeta
936957
}
937958

@@ -981,6 +1002,9 @@ export class FormApi<
9811002

9821003
const isTouched = fieldMetaValues.some((field) => field.isTouched)
9831004
const isBlurred = fieldMetaValues.some((field) => field.isBlurred)
1005+
const isDefaultValue = fieldMetaValues.every(
1006+
(field) => field.isDefaultValue,
1007+
)
9841008

9851009
const shouldInvalidateOnMount =
9861010
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
@@ -1059,8 +1083,9 @@ export class FormApi<
10591083
prevVal.isTouched === isTouched &&
10601084
prevVal.isBlurred === isBlurred &&
10611085
prevVal.isPristine === isPristine &&
1086+
prevVal.isDefaultValue === isDefaultValue &&
10621087
prevVal.isDirty === isDirty &&
1063-
shallow(prevBaseStore, currBaseStore)
1088+
evaluate(prevBaseStore, currBaseStore)
10641089
) {
10651090
return prevVal
10661091
}
@@ -1078,6 +1103,7 @@ export class FormApi<
10781103
isTouched,
10791104
isBlurred,
10801105
isPristine,
1106+
isDefaultValue,
10811107
isDirty,
10821108
} as FormState<
10831109
TFormData,
@@ -1187,11 +1213,11 @@ export class FormApi<
11871213

11881214
const shouldUpdateValues =
11891215
options.defaultValues &&
1190-
!shallow(options.defaultValues, oldOptions.defaultValues) &&
1216+
!evaluate(options.defaultValues, oldOptions.defaultValues) &&
11911217
!this.state.isTouched
11921218

11931219
const shouldUpdateState =
1194-
!shallow(options.defaultState, oldOptions.defaultState) &&
1220+
!evaluate(options.defaultState, oldOptions.defaultState) &&
11951221
!this.state.isTouched
11961222

11971223
if (!shouldUpdateValues && !shouldUpdateState && !shouldUpdateReeval) return

packages/form-core/src/metaHelper.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ export const defaultFieldMeta: AnyFieldMeta = {
1515
isDirty: false,
1616
isPristine: true,
1717
isValid: true,
18+
isDefaultValue: true,
1819
errors: [],
1920
errorMap: {},
2021
errorSourceMap: {},

packages/form-core/src/utils.ts

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -336,7 +336,7 @@ export const isGlobalFormValidationError = (
336336
return !!error && typeof error === 'object' && 'fields' in error
337337
}
338338

339-
export function shallow<T>(objA: T, objB: T) {
339+
export function evaluate<T>(objA: T, objB: T) {
340340
if (Object.is(objA, objB)) {
341341
return true
342342
}
@@ -367,18 +367,23 @@ export function shallow<T>(objA: T, objB: T) {
367367
}
368368

369369
const keysA = Object.keys(objA)
370-
if (keysA.length !== Object.keys(objB).length) {
370+
const keysB = Object.keys(objB)
371+
372+
if (keysA.length !== keysB.length) {
371373
return false
372374
}
373375

374-
for (let i = 0; i < keysA.length; i++) {
376+
for (const key of keysA) {
377+
// performs recursive search down the object tree
378+
375379
if (
376-
!Object.prototype.hasOwnProperty.call(objB, keysA[i] as string) ||
377-
!Object.is(objA[keysA[i] as keyof T], objB[keysA[i] as keyof T])
380+
!keysB.includes(key) ||
381+
!evaluate(objA[key as keyof T], objB[key as keyof T])
378382
) {
379383
return false
380384
}
381385
}
386+
382387
return true
383388
}
384389

0 commit comments

Comments
 (0)