Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added docs/assets/field-states-extended.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/assets/field-states.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
31 changes: 27 additions & 4 deletions docs/framework/react/guides/basic-concepts.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,17 +92,40 @@ 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
```

![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

Expand Down
4 changes: 4 additions & 0 deletions packages/form-core/src/FieldApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<
Expand Down
48 changes: 37 additions & 11 deletions packages/form-core/src/FormApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,14 @@ import { Derived, Store, batch } from '@tanstack/store'
import {
deleteBy,
determineFormLevelErrorSourceAndValue,
evaluate,
functionalUpdate,
getAsyncValidatorArray,
getBy,
getSyncValidatorArray,
isGlobalFormValidationError,
isNonEmptyArray,
setBy,
shallow,
} from './utils'

import {
Expand Down Expand Up @@ -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.
*/
Expand Down Expand Up @@ -883,21 +887,26 @@ export class FormApi<
for (const fieldName of Object.keys(
currBaseStore.fieldMetaBase,
) as Array<keyof typeof currBaseStore.fieldMetaBase>) {
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

Expand All @@ -912,26 +921,38 @@ 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++
continue
}

fieldMeta[fieldName] = {
...currBaseVal,
...currBaseMeta,
errors: fieldErrors,
isPristine: isFieldPristine,
isValid: isFieldValid,
isDefaultValue: isDefaultValue,
} as AnyFieldMeta
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Expand All @@ -1078,6 +1103,7 @@ export class FormApi<
isTouched,
isBlurred,
isPristine,
isDefaultValue,
isDirty,
} as FormState<
TFormData,
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions packages/form-core/src/metaHelper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export const defaultFieldMeta: AnyFieldMeta = {
isDirty: false,
isPristine: true,
isValid: true,
isDefaultValue: true,
errors: [],
errorMap: {},
errorSourceMap: {},
Expand Down
15 changes: 10 additions & 5 deletions packages/form-core/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -336,7 +336,7 @@ export const isGlobalFormValidationError = (
return !!error && typeof error === 'object' && 'fields' in error
}

export function shallow<T>(objA: T, objB: T) {
export function evaluate<T>(objA: T, objB: T) {
if (Object.is(objA, objB)) {
return true
}
Expand Down Expand Up @@ -367,18 +367,23 @@ export function shallow<T>(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
}

Expand Down
Loading