diff --git a/docs/assets/field-states-extended.png b/docs/assets/field-states-extended.png new file mode 100644 index 000000000..033fe6e68 Binary files /dev/null and b/docs/assets/field-states-extended.png differ diff --git a/docs/assets/field-states.png b/docs/assets/field-states.png index 2e6490cd8..a78ed16f9 100644 Binary files a/docs/assets/field-states.png and b/docs/assets/field-states.png differ diff --git a/docs/framework/react/guides/basic-concepts.md b/docs/framework/react/guides/basic-concepts.md index 5b3e5d3a3..4e4ece2e5 100644 --- a/docs/framework/react/guides/basic-concepts.md +++ b/docs/framework/react/guides/basic-concepts.md @@ -92,7 +92,11 @@ const { } = field.state ``` -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. +There are three states in the metadata that can be useful to see how the user interacts with a field: + +- _"isTouched"_, after the user clicks/tabs into the field +- _"isPristine"_, until the user changes the field value +- _"isDirty"_, after the fields value has been changed ```tsx const { isTouched, isPristine, isDirty } = field.state.meta @@ -100,9 +104,28 @@ const { isTouched, isPristine, isDirty } = field.state.meta ![Field states](https://raw.githubusercontent.com/TanStack/form/main/docs/assets/field-states.png) -> **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. -> 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`. -> 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.` +## Understanding 'isDirty' in Different Libraries + +Non-Persistent `dirty` state + +- **Libraries**: React Hook Form (RHF), Formik, Final Form. +- **Behavior**: A field is 'dirty' if its value differs from the default. Reverting to the default value makes it 'clean' again. + +Persistent `dirty` state + +- **Libraries**: Angular Form, Vue FormKit. +- **Behavior**: A field remains 'dirty' once changed, even if reverted to the default value. + +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. + +```tsx +const { isTouched, isPristine, isDirty, isDefaultValue } = field.state.meta + +// The following line will re-create the non-Persistent `dirty` functionality. +const nonPersistentIsDirty = !isDefaultValue +``` + +![Field states extended](https://raw.githubusercontent.com/TanStack/form/main/docs/assets/field-states-extended.png) ## Field API diff --git a/packages/form-core/src/FieldApi.ts b/packages/form-core/src/FieldApi.ts index 2b2d9882a..dac3c2571 100644 --- a/packages/form-core/src/FieldApi.ts +++ b/packages/form-core/src/FieldApi.ts @@ -656,6 +656,10 @@ export type FieldMetaDerived< * A boolean indicating if the field is valid. Evaluates `true` if there are no field errors. */ isValid: boolean + /** + * A flag indicating whether the field's current value is the default value + */ + isDefaultValue: boolean } export type AnyFieldMetaDerived = FieldMetaDerived< diff --git a/packages/form-core/src/FormApi.ts b/packages/form-core/src/FormApi.ts index d1c990646..45242067e 100644 --- a/packages/form-core/src/FormApi.ts +++ b/packages/form-core/src/FormApi.ts @@ -2,6 +2,7 @@ import { Derived, Store, batch } from '@tanstack/store' import { deleteBy, determineFormLevelErrorSourceAndValue, + evaluate, functionalUpdate, getAsyncValidatorArray, getBy, @@ -9,7 +10,6 @@ import { isGlobalFormValidationError, isNonEmptyArray, setBy, - shallow, } from './utils' import { @@ -626,6 +626,10 @@ export type DerivedFormState< * 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`. */ isPristine: boolean + /** + * A boolean indicating if all of the form's fields are the same as default values. + */ + isDefaultValue: boolean /** * A boolean indicating if the form and all its fields are valid. Evaluates `true` if there are no errors. */ @@ -883,21 +887,26 @@ export class FormApi< for (const fieldName of Object.keys( currBaseStore.fieldMetaBase, ) as Array) { - const currBaseVal = currBaseStore.fieldMetaBase[ + const currBaseMeta = currBaseStore.fieldMetaBase[ fieldName as never ] as AnyFieldMetaBase - const prevBaseVal = prevBaseStore?.fieldMetaBase[ + const prevBaseMeta = prevBaseStore?.fieldMetaBase[ fieldName as never ] as AnyFieldMetaBase | undefined const prevFieldInfo = prevVal?.[fieldName as never as keyof typeof prevVal] + const curFieldVal = getBy(currBaseStore.values, fieldName) + let fieldErrors = prevFieldInfo?.errors - if (!prevBaseVal || currBaseVal.errorMap !== prevBaseVal.errorMap) { + if ( + !prevBaseMeta || + currBaseMeta.errorMap !== prevBaseMeta.errorMap + ) { // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - fieldErrors = Object.values(currBaseVal.errorMap ?? {}).filter( + fieldErrors = Object.values(currBaseMeta.errorMap ?? {}).filter( (val) => val !== undefined, ) as never @@ -912,15 +921,26 @@ export class FormApi< } // As primitives, we don't need to aggressively persist the same referential value for performance reasons - const isFieldPristine = !currBaseVal.isDirty const isFieldValid = !isNonEmptyArray(fieldErrors ?? []) + const isFieldPristine = !currBaseMeta.isDirty + const isDefaultValue = + evaluate( + curFieldVal, + getBy(this.options.defaultValues, fieldName), + ) || + evaluate( + curFieldVal, + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + this.getFieldInfo(fieldName)?.instance?.options.defaultValue, + ) if ( prevFieldInfo && prevFieldInfo.isPristine === isFieldPristine && prevFieldInfo.isValid === isFieldValid && + prevFieldInfo.isDefaultValue === isDefaultValue && prevFieldInfo.errors === fieldErrors && - currBaseVal === prevBaseVal + currBaseMeta === prevBaseMeta ) { fieldMeta[fieldName] = prevFieldInfo originalMetaCount++ @@ -928,10 +948,11 @@ export class FormApi< } fieldMeta[fieldName] = { - ...currBaseVal, + ...currBaseMeta, errors: fieldErrors, isPristine: isFieldPristine, isValid: isFieldValid, + isDefaultValue: isDefaultValue, } as AnyFieldMeta } @@ -981,6 +1002,9 @@ export class FormApi< const isTouched = fieldMetaValues.some((field) => field.isTouched) const isBlurred = fieldMetaValues.some((field) => field.isBlurred) + const isDefaultValue = fieldMetaValues.every( + (field) => field.isDefaultValue, + ) const shouldInvalidateOnMount = // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition @@ -1059,8 +1083,9 @@ export class FormApi< prevVal.isTouched === isTouched && prevVal.isBlurred === isBlurred && prevVal.isPristine === isPristine && + prevVal.isDefaultValue === isDefaultValue && prevVal.isDirty === isDirty && - shallow(prevBaseStore, currBaseStore) + evaluate(prevBaseStore, currBaseStore) ) { return prevVal } @@ -1078,6 +1103,7 @@ export class FormApi< isTouched, isBlurred, isPristine, + isDefaultValue, isDirty, } as FormState< TFormData, @@ -1187,11 +1213,11 @@ export class FormApi< const shouldUpdateValues = options.defaultValues && - !shallow(options.defaultValues, oldOptions.defaultValues) && + !evaluate(options.defaultValues, oldOptions.defaultValues) && !this.state.isTouched const shouldUpdateState = - !shallow(options.defaultState, oldOptions.defaultState) && + !evaluate(options.defaultState, oldOptions.defaultState) && !this.state.isTouched if (!shouldUpdateValues && !shouldUpdateState && !shouldUpdateReeval) return diff --git a/packages/form-core/src/metaHelper.ts b/packages/form-core/src/metaHelper.ts index ef1c214da..9ecc6fed8 100644 --- a/packages/form-core/src/metaHelper.ts +++ b/packages/form-core/src/metaHelper.ts @@ -15,6 +15,7 @@ export const defaultFieldMeta: AnyFieldMeta = { isDirty: false, isPristine: true, isValid: true, + isDefaultValue: true, errors: [], errorMap: {}, errorSourceMap: {}, diff --git a/packages/form-core/src/utils.ts b/packages/form-core/src/utils.ts index 5e74c1726..6060cb68f 100644 --- a/packages/form-core/src/utils.ts +++ b/packages/form-core/src/utils.ts @@ -336,7 +336,7 @@ export const isGlobalFormValidationError = ( return !!error && typeof error === 'object' && 'fields' in error } -export function shallow(objA: T, objB: T) { +export function evaluate(objA: T, objB: T) { if (Object.is(objA, objB)) { return true } @@ -367,18 +367,23 @@ export function shallow(objA: T, objB: T) { } const keysA = Object.keys(objA) - if (keysA.length !== Object.keys(objB).length) { + const keysB = Object.keys(objB) + + if (keysA.length !== keysB.length) { return false } - for (let i = 0; i < keysA.length; i++) { + for (const key of keysA) { + // performs recursive search down the object tree + if ( - !Object.prototype.hasOwnProperty.call(objB, keysA[i] as string) || - !Object.is(objA[keysA[i] as keyof T], objB[keysA[i] as keyof T]) + !keysB.includes(key) || + !evaluate(objA[key as keyof T], objB[key as keyof T]) ) { return false } } + return true } diff --git a/packages/form-core/tests/FieldApi.spec.ts b/packages/form-core/tests/FieldApi.spec.ts index 01cd7eb77..c10c3fa9c 100644 --- a/packages/form-core/tests/FieldApi.spec.ts +++ b/packages/form-core/tests/FieldApi.spec.ts @@ -57,6 +57,7 @@ describe('field api', () => { expect(field.getMeta()).toEqual({ isTouched: false, isBlurred: false, + isDefaultValue: true, isValidating: false, isPristine: true, isValid: true, @@ -77,48 +78,160 @@ describe('field api', () => { name: 'name', defaultMeta: { isTouched: true, - isDirty: true, - isPristine: false, isBlurred: true, + isDirty: true, }, }) field.mount() - expect(field.getMeta()).toEqual({ + expect(field.getMeta()).toMatchObject({ isTouched: true, isBlurred: true, - isValidating: false, isDirty: true, - isPristine: false, + + // derived meta data isValid: true, + isValidating: false, errors: [], errorMap: {}, errorSourceMap: {}, }) }) - it('should set a value correctly', () => { + it('should update the fields meta isDefaultValue with primitives', () => { const form = new FormApi({ defaultValues: { name: 'test', }, }) - form.mount() const field = new FieldApi({ - form, + form: form, name: 'name', + defaultValue: 'another-test', + }) + field.mount() + + expect(field.getMeta().isDefaultValue).toBe(true) + + field.setValue('not-test') + expect(field.getMeta().isDefaultValue).toBe(false) + + field.setValue('test') + expect(field.getMeta().isDefaultValue).toBe(true) + + form.resetField('name') + expect(field.getMeta().isDefaultValue).toBe(true) + + // checks the defaultValue provided to the field + field.setValue('another-test') + expect(field.getMeta().isDefaultValue).toBe(true) + }) + + it('should update the fields meta isDefaultValue with arrays - simple', () => { + const form = new FormApi({ + defaultValues: { + arr: ['', ''], + }, }) + form.mount() + const field = new FieldApi({ + form: form, + name: 'arr', + }) field.mount() - field.setValue('other', { - dontUpdateMeta: true, + expect(field.getMeta().isDefaultValue).toBe(true) + + field.setValue(['hello', 'goodbye']) + expect(field.getMeta().isDefaultValue).toBe(false) + + field.setValue(['', '']) + expect(field.getMeta().isDefaultValue).toBe(true) + }) + + it('should update the fields meta isDefaultValue with arrays - complex', () => { + const defaultValues: [{ age: number; name?: string }, null | undefined] = [ + { age: 0 }, + undefined, + ] + const form = new FormApi({ + defaultValues: { + arr: defaultValues, + }, + }) + form.mount() + + const field = new FieldApi({ + form: form, + name: 'arr', }) - expect(field.getValue()).toBe('other') + field.mount() + expect(field.getMeta().isDefaultValue).toBe(true) + + field.setValue([{ age: 0, name: '' }, null]) + expect(field.getMeta().isDefaultValue).toBe(false) + + field.setValue([{ age: 0 }, undefined]) + expect(field.getMeta().isDefaultValue).toBe(true) + }) + + it('should update the fields meta isDefaultValue with objects - simple', () => { + const objectMetaForm = new FormApi({ + defaultValues: { + obj: { firstName: 'John', lastName: 'Wick' }, + }, + }) + objectMetaForm.mount() + + const objectField = new FieldApi({ + form: objectMetaForm, + name: 'obj', + }) + objectField.mount() + + expect(objectField.getMeta().isDefaultValue).toBe(true) + + objectField.setValue({ firstName: 'John', lastName: 'Travolta' }) + expect(objectField.getMeta().isDefaultValue).toBe(false) + + objectField.setValue({ firstName: 'John', lastName: 'Wick' }) + expect(objectField.getMeta().isDefaultValue).toBe(true) + }) + + it('should update the fields meta isDefaultValue with objects - complex', () => { + const defaultValues: { arr: [number, object]; test?: string } = { + arr: [0, {}], + } + const form = new FormApi({ + defaultValues: { + obj: defaultValues, + }, + }) + form.mount() + + const field = new FieldApi({ + form: form, + name: 'obj', + }) + field.mount() + + expect(field.getMeta().isDefaultValue).toBe(true) + + field.setValue({ + arr: [1, {}], + test: 'hi', + }) + expect(field.getMeta().isDefaultValue).toBe(false) + + field.setValue({ + arr: [0, {}], + }) + expect(field.getMeta().isDefaultValue).toBe(true) }) it('should set isBlurred correctly', () => { diff --git a/packages/form-core/tests/FormApi.spec.ts b/packages/form-core/tests/FormApi.spec.ts index 292fce077..21f1b1aad 100644 --- a/packages/form-core/tests/FormApi.spec.ts +++ b/packages/form-core/tests/FormApi.spec.ts @@ -3080,6 +3080,34 @@ describe('form api', () => { expect(field.state.meta.isTouched).toBe(false) }) + it('should set the form isDefaultValue meta', async () => { + const form = new FormApi({ + defaultValues: { + name: 'tony', + lastName: 'hawk', + }, + }) + form.mount() + + const nameField = new FieldApi({ + form, + name: 'name', + }) + nameField.mount() + + const lastNameField = new FieldApi({ + form, + name: 'lastName', + }) + lastNameField.mount() + + lastNameField.setValue('') + expect(form.state.isDefaultValue).toBe(false) + + lastNameField.setValue('hawk') + expect(form.state.isDefaultValue).toBe(true) + }) + it('should allow submission, when the form is invalid, with canSubmitWhenInvalid', async () => { const form = new FormApi({ defaultValues: { diff --git a/packages/form-core/tests/utils.spec.ts b/packages/form-core/tests/utils.spec.ts index dbaad093a..18a493ae0 100644 --- a/packages/form-core/tests/utils.spec.ts +++ b/packages/form-core/tests/utils.spec.ts @@ -3,6 +3,7 @@ import { deleteBy, determineFieldLevelErrorSourceAndValue, determineFormLevelErrorSourceAndValue, + evaluate, getBy, makePathArray, setBy, @@ -492,3 +493,74 @@ describe('determineFieldLevelErrorSourceAndValue', () => { }) }) }) + +describe('evaluate', () => { + it('should test equality between primitives', () => { + const numbersTrue = evaluate(1, 1) + expect(numbersTrue).toEqual(true) + + const stringFalse = evaluate('uh oh', '') + expect(stringFalse).toEqual(false) + + const boolTrue = evaluate(true, true) + expect(boolTrue).toEqual(true) + + const nullFalse = evaluate(null, {}) + expect(nullFalse).toEqual(false) + + const undefinedFalse = evaluate(undefined, null) + expect(undefinedFalse).toEqual(false) + }) + + it('should test equality between arrays', () => { + const arrayTrue = evaluate([], []) + expect(arrayTrue).toEqual(true) + + const arrayDeepSearchTrue = evaluate([[1]], [[1]]) + expect(arrayDeepSearchTrue).toEqual(true) + + const arrayFalse = evaluate([], ['']) + expect(arrayFalse).toEqual(false) + + const arrayDeepFalse = evaluate([[1]], []) + expect(arrayDeepFalse).toEqual(false) + + const arrayComplexFalse = evaluate([[{ test: 'true' }], null], [[1], {}]) + expect(arrayComplexFalse).toEqual(false) + + const arrayComplexTrue = evaluate( + [[{ test: 'true' }], null], + [[{ test: 'true' }], null], + ) + expect(arrayComplexTrue).toEqual(true) + }) + + it('should test equality between objects', () => { + const objTrue = evaluate({ test: 'same' }, { test: 'same' }) + expect(objTrue).toEqual(true) + + const objFalse = evaluate({ test: 'not' }, { test: 'same' }) + expect(objFalse).toEqual(false) + + const objDeepFalse = evaluate({ test: 'not' }, { test: { test: 'same' } }) + expect(objDeepFalse).toEqual(false) + + const objDeepArrFalse = evaluate({ test: [] }, { test: [[]] }) + expect(objDeepArrFalse).toEqual(false) + + const objNullFalse = evaluate({ test: '' }, null) + expect(objNullFalse).toEqual(false) + + const objComplexFalse = evaluate( + { test: { testTwo: '' }, arr: [[1]] }, + { test: { testTwo: false }, arr: [[1], [0]] }, + ) + expect(objComplexFalse).toEqual(false) + + const objComplexTrue = evaluate( + { test: { testTwo: '' }, arr: [[1]] }, + { test: { testTwo: '' }, arr: [[1]] }, + ) + expect(objComplexTrue).toEqual(true) + }) +})