diff --git a/.changeset/quiet-groups-render.md b/.changeset/quiet-groups-render.md new file mode 100644 index 0000000000..df571d67b8 --- /dev/null +++ b/.changeset/quiet-groups-render.md @@ -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. diff --git a/.changeset/quiet-preact-groups-render.md b/.changeset/quiet-preact-groups-render.md new file mode 100644 index 0000000000..588a3ce313 --- /dev/null +++ b/.changeset/quiet-preact-groups-render.md @@ -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. diff --git a/docs/framework/preact/guides/form-groups.md b/docs/framework/preact/guides/form-groups.md index f3d908d31b..00af0958ee 100644 --- a/docs/framework/preact/guides/form-groups.md +++ b/docs/framework/preact/guides/form-groups.md @@ -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. diff --git a/docs/framework/react/guides/form-groups.md b/docs/framework/react/guides/form-groups.md index f3d908d31b..00af0958ee 100644 --- a/docs/framework/react/guides/form-groups.md +++ b/docs/framework/react/guides/form-groups.md @@ -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. diff --git a/packages/preact-form/src/useFormGroup.tsx b/packages/preact-form/src/useFormGroup.tsx index 3cd22d6a0d..1bcd199ee4 100644 --- a/packages/preact-form/src/useFormGroup.tsx +++ b/packages/preact-form/src/useFormGroup.tsx @@ -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' @@ -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(), ) - 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[]) => { @@ -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]) diff --git a/packages/preact-form/tests/useFormGroup.test.tsx b/packages/preact-form/tests/useFormGroup.test.tsx index c2e73a683f..c7492a3165 100644 --- a/packages/preact-form/tests/useFormGroup.test.tsx +++ b/packages/preact-form/tests/useFormGroup.test.tsx @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from 'vitest' import { render, waitFor } from '@testing-library/preact' import { userEvent } from '@testing-library/user-event' +import { useState } from 'preact/hooks' import { useForm } from '../src/index' const user = userEvent.setup() @@ -320,4 +321,350 @@ describe('form.FormGroup', () => { expect(button.disabled).toBe(false) expect(onGroupSubmit).toHaveBeenCalledTimes(1) }) + + it('should not rerender group children when a field changes and group state is not read', async () => { + const renderGroupChildren = vi.fn() + + function Comp() { + const form = useForm({ + defaultValues: { + step1: { firstName: '', lastName: '' }, + }, + }) + + return ( + + {() => { + renderGroupChildren() + return ( + ( + field.handleChange(e.currentTarget.value)} + /> + )} + /> + ) + }} + + ) + } + + const { getByTestId } = render() + const rendersAfterMount = renderGroupChildren.mock.calls.length + + await user.type(getByTestId('first-name'), 'abc') + + expect(getByTestId('first-name')).toHaveValue('abc') + expect(renderGroupChildren).toHaveBeenCalledTimes(rendersAfterMount) + }) + + it('should rerender for group meta it reads but not for unrelated value changes', async () => { + const renderGroupChildren = vi.fn() + + function Comp() { + const form = useForm({ + defaultValues: { + step1: { firstName: '' }, + }, + }) + + return ( + + value.firstName.includes('!') + ? 'No exclamation marks' + : undefined, + }} + > + {(group) => { + renderGroupChildren() + return ( + <> + ( + field.handleChange(e.currentTarget.value)} + /> + )} + /> +
+                  {JSON.stringify(group.state.meta.errorMap)}
+                
+ + ) + }} +
+ ) + } + + const { getByTestId } = render() + const rendersAfterMount = renderGroupChildren.mock.calls.length + + await user.type(getByTestId('first-name'), 'abc') + expect(renderGroupChildren).toHaveBeenCalledTimes(rendersAfterMount) + + await user.type(getByTestId('first-name'), '!') + await waitFor(() => + expect(getByTestId('group-errors')).toHaveTextContent( + 'No exclamation marks', + ), + ) + }) + + it('should read the current group value in handlers without subscribing up front', async () => { + const onRead = vi.fn() + + function Comp() { + const form = useForm({ + defaultValues: { + step1: { firstName: '' }, + }, + }) + + return ( + + {(group) => ( + <> + ( + field.handleChange(e.currentTarget.value)} + /> + )} + /> +