Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/quiet-groups-render.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/react-form': patch
---

Rerender `FormGroup` only when group state it reads changes, instead of on every field change in the group.
5 changes: 5 additions & 0 deletions .changeset/quiet-preact-groups-render.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/preact-form': patch
---

Rerender `FormGroup` only when group state it reads changes, instead of on every field change in the group.
2 changes: 2 additions & 0 deletions docs/framework/preact/guides/form-groups.md
Original file line number Diff line number Diff line change
Expand Up @@ -175,3 +175,5 @@ Just like you're able to access `group.state.meta.errors`, you're also able to a
- `group.state.meta.isGroupValid`: `true` when the group-level validators have no errors
- `group.state.meta.isValid`: `true` when both the field-level and group-level validators have no errors
- `group.state.meta.isSubmitting`: `true` when the group is in the process of being submitted

A group only rerenders when a part of `group.state` that it has read changes. A group that renders only `form.Field`s therefore does not rerender while those fields are edited; each field rerenders on its own.
2 changes: 2 additions & 0 deletions docs/framework/react/guides/form-groups.md
Original file line number Diff line number Diff line change
Expand Up @@ -175,3 +175,5 @@ Just like you're able to access `group.state.meta.errors`, you're also able to a
- `group.state.meta.isGroupValid`: `true` when the group-level validators have no errors
- `group.state.meta.isValid`: `true` when both the field-level and group-level validators have no errors
- `group.state.meta.isSubmitting`: `true` when the group is in the process of being submitted

A group only rerenders when a part of `group.state` that it has read changes. A group that renders only `form.Field`s therefore does not rerender while those fields are edited; each field rerenders on its own.
168 changes: 35 additions & 133 deletions packages/preact-form/src/useFormGroup.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useMemo, useState } from 'preact/hooks'
import { useMemo, useRef, useState } from 'preact/hooks'
import { useSelector } from '@tanstack/preact-store'
import { FormGroupApi, functionalUpdate } from '@tanstack/form-core'
import { useIsomorphicLayoutEffect } from './useIsomorphicLayoutEffect'
Expand Down Expand Up @@ -201,77 +201,27 @@ export function useFormGroup<
setPrevOptions({ form: opts.form, name: opts.name })
}

const reactiveStateValue = useSelector(
formGroupApi.store,
(state) => state.value,
const trackedKeysRef = useRef(
new Set<keyof typeof formGroupApi.state.meta | 'value'>(),
)

const reactiveMetaIsTouched = useSelector(
formGroupApi.store,
(state) => state.meta.isTouched,
)
const reactiveMetaIsBlurred = useSelector(
formGroupApi.store,
(state) => state.meta.isBlurred,
)
const reactiveMetaIsDirty = useSelector(
formGroupApi.store,
(state) => state.meta.isDirty,
)
const reactiveMetaErrorMap = useSelector(
const trackedState = useSelector(
formGroupApi.store,
(state) => state.meta.errorMap,
)
const reactiveMetaErrorSourceMap = useSelector(
formGroupApi.store,
(state) => state.meta.errorSourceMap,
)
const reactiveMetaIsValidating = useSelector(
formGroupApi.store,
(state) => state.meta.isValidating,
(state) =>
[...trackedKeysRef.current].map((key) =>
key === 'value' ? state.value : state.meta[key],
),
{
compare: (prev, next) =>
prev.length === next.length &&
prev.every((value, i) => value === next[i]),
},
)

// Submission lifecycle and aggregated validity now live on `state.meta`
// (mirroring `FieldApi.state.meta`).
const reactiveMetaIsSubmitting = useSelector(
formGroupApi.store,
(state) => state.meta.isSubmitting,
)
const reactiveMetaIsSubmitted = useSelector(
formGroupApi.store,
(state) => state.meta.isSubmitted,
)
const reactiveMetaSubmissionAttempts = useSelector(
formGroupApi.store,
(state) => state.meta.submissionAttempts,
)
const reactiveMetaIsSubmitSuccessful = useSelector(
formGroupApi.store,
(state) => state.meta.isSubmitSuccessful,
)
const reactiveMetaCanSubmit = useSelector(
formGroupApi.store,
(state) => state.meta.canSubmit,
)
const reactiveMetaIsValid = useSelector(
formGroupApi.store,
(state) => state.meta.isValid,
)
const reactiveMetaIsFieldsValid = useSelector(
formGroupApi.store,
(state) => state.meta.isFieldsValid,
)
const reactiveMetaIsFieldsValidating = useSelector(
formGroupApi.store,
(state) => state.meta.isFieldsValidating,
)
const reactiveMetaIsGroupValid = useSelector(
formGroupApi.store,
(state) => state.meta.isGroupValid,
)

// This makes me sad, but if I understand correctly, this is what we have to do for reactivity to work properly with React compiler.
const extendedFieldApi = useMemo(() => {
// Consumers memoized on `group` (e.g. with `memo`)
// need a new identity whenever a tracked slice changes
void trackedState
const reactiveFieldApi = {
...formGroupApi,
handleSubmit: ((...props: never[]) => {
Expand All @@ -280,78 +230,30 @@ export function useFormGroup<
get state() {
return {
...formGroupApi.state,
value: reactiveStateValue,
get value() {
trackedKeysRef.current.add('value')
return formGroupApi.state.value
},
get meta() {
return {
...formGroupApi.state.meta,
isTouched: reactiveMetaIsTouched,
isBlurred: reactiveMetaIsBlurred,
isDirty: reactiveMetaIsDirty,
errorMap: reactiveMetaErrorMap,
errorSourceMap: reactiveMetaErrorSourceMap,
isValidating: reactiveMetaIsValidating,
isSubmitting: reactiveMetaIsSubmitting,
isSubmitted: reactiveMetaIsSubmitted,
submissionAttempts: reactiveMetaSubmissionAttempts,
isSubmitSuccessful: reactiveMetaIsSubmitSuccessful,
canSubmit: reactiveMetaCanSubmit,
isValid: reactiveMetaIsValid,
isFieldsValid: reactiveMetaIsFieldsValid,
isFieldsValidating: reactiveMetaIsFieldsValidating,
isGroupValid: reactiveMetaIsGroupValid,
} satisfies typeof formGroupApi.state.meta
const trackedMeta = { ...formGroupApi.state.meta }
for (const key of Object.keys(
trackedMeta,
) as (keyof typeof trackedMeta)[]) {
Object.defineProperty(trackedMeta, key, {
enumerable: true,
get() {
trackedKeysRef.current.add(key)
return formGroupApi.state.meta[key]
},
})
}
return trackedMeta
},
} satisfies typeof formGroupApi.state
},
}

const extendedApi: FormGroupApi<
TParentData,
TName,
TData,
TOnMount,
TOnChange,
TOnChangeAsync,
TOnBlur,
TOnBlurAsync,
TOnSubmit,
TOnSubmitAsync,
TOnDynamic,
TOnDynamicAsync,
TSubmitMeta,
TFormOnMount,
TFormOnChange,
TFormOnChangeAsync,
TFormOnBlur,
TFormOnBlurAsync,
TFormOnSubmit,
TFormOnSubmitAsync,
TFormOnDynamic,
TFormOnDynamicAsync,
TFormOnServer,
TParentSubmitMeta
> = reactiveFieldApi as never

return extendedApi
}, [
formGroupApi,
reactiveStateValue,
reactiveMetaIsTouched,
reactiveMetaIsBlurred,
reactiveMetaIsDirty,
reactiveMetaErrorMap,
reactiveMetaErrorSourceMap,
reactiveMetaIsValidating,
reactiveMetaIsSubmitting,
reactiveMetaIsSubmitted,
reactiveMetaSubmissionAttempts,
reactiveMetaIsSubmitSuccessful,
reactiveMetaCanSubmit,
reactiveMetaIsValid,
reactiveMetaIsFieldsValid,
reactiveMetaIsFieldsValidating,
reactiveMetaIsGroupValid,
])
return reactiveFieldApi as never as typeof formGroupApi
}, [formGroupApi, trackedState])

useIsomorphicLayoutEffect(formGroupApi.mount, [formGroupApi])

Expand Down
Loading