Skip to content

Commit 5ea296e

Browse files
committed
fix(preact-form): track read FormGroup state
The Preact useFormGroup is a line-for-line copy of the React hook and shares its defect: any field change in a group re-ran the render prop and all of its children, even when nothing there read group state. Apply the same tracked subscription as in react-form, so the group only re-renders when state that has been read changes. Refs #2377
1 parent 5b7ea71 commit 5ea296e

4 files changed

Lines changed: 208 additions & 135 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@tanstack/preact-form': patch
3+
---
4+
5+
Rerender `FormGroup` only when group state read during render changes, instead of on every field change in the group.

‎docs/framework/preact/guides/form-groups.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,3 +175,5 @@ Just like you're able to access `group.state.meta.errors`, you're also able to a
175175
- `group.state.meta.isGroupValid`: `true` when the group-level validators have no errors
176176
- `group.state.meta.isValid`: `true` when both the field-level and group-level validators have no errors
177177
- `group.state.meta.isSubmitting`: `true` when the group is in the process of being submitted
178+
179+
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.

‎packages/preact-form/src/useFormGroup.tsx‎

Lines changed: 33 additions & 135 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useMemo, useState } from 'preact/hooks'
1+
import { useMemo, useRef, useState } from 'preact/hooks'
22
import { useSelector } from '@tanstack/preact-store'
33
import { FormGroupApi, functionalUpdate } from '@tanstack/form-core'
44
import { useIsomorphicLayoutEffect } from './useIsomorphicLayoutEffect'
@@ -201,77 +201,23 @@ export function useFormGroup<
201201
setPrevOptions({ form: opts.form, name: opts.name })
202202
}
203203

204-
const reactiveStateValue = useSelector(
205-
formGroupApi.store,
206-
(state) => state.value,
204+
const trackedKeysRef = useRef(
205+
new Set<keyof typeof formGroupApi.state.meta | 'value'>(),
207206
)
208207

209-
const reactiveMetaIsTouched = useSelector(
210-
formGroupApi.store,
211-
(state) => state.meta.isTouched,
212-
)
213-
const reactiveMetaIsBlurred = useSelector(
214-
formGroupApi.store,
215-
(state) => state.meta.isBlurred,
216-
)
217-
const reactiveMetaIsDirty = useSelector(
218-
formGroupApi.store,
219-
(state) => state.meta.isDirty,
220-
)
221-
const reactiveMetaErrorMap = useSelector(
222-
formGroupApi.store,
223-
(state) => state.meta.errorMap,
224-
)
225-
const reactiveMetaErrorSourceMap = useSelector(
226-
formGroupApi.store,
227-
(state) => state.meta.errorSourceMap,
228-
)
229-
const reactiveMetaIsValidating = useSelector(
230-
formGroupApi.store,
231-
(state) => state.meta.isValidating,
232-
)
233-
234-
// Submission lifecycle and aggregated validity now live on `state.meta`
235-
// (mirroring `FieldApi.state.meta`).
236-
const reactiveMetaIsSubmitting = useSelector(
237-
formGroupApi.store,
238-
(state) => state.meta.isSubmitting,
239-
)
240-
const reactiveMetaIsSubmitted = useSelector(
241-
formGroupApi.store,
242-
(state) => state.meta.isSubmitted,
243-
)
244-
const reactiveMetaSubmissionAttempts = useSelector(
245-
formGroupApi.store,
246-
(state) => state.meta.submissionAttempts,
247-
)
248-
const reactiveMetaIsSubmitSuccessful = useSelector(
249-
formGroupApi.store,
250-
(state) => state.meta.isSubmitSuccessful,
251-
)
252-
const reactiveMetaCanSubmit = useSelector(
253-
formGroupApi.store,
254-
(state) => state.meta.canSubmit,
255-
)
256-
const reactiveMetaIsValid = useSelector(
257-
formGroupApi.store,
258-
(state) => state.meta.isValid,
259-
)
260-
const reactiveMetaIsFieldsValid = useSelector(
261-
formGroupApi.store,
262-
(state) => state.meta.isFieldsValid,
263-
)
264-
const reactiveMetaIsFieldsValidating = useSelector(
265-
formGroupApi.store,
266-
(state) => state.meta.isFieldsValidating,
267-
)
268-
const reactiveMetaIsGroupValid = useSelector(
269-
formGroupApi.store,
270-
(state) => state.meta.isGroupValid,
271-
)
208+
const trackedState = useSelector(formGroupApi.store, (state) => state, {
209+
compare: (prev, next) =>
210+
[...trackedKeysRef.current].every((key) =>
211+
key === 'value'
212+
? prev.value === next.value
213+
: prev.meta[key] === next.meta[key],
214+
),
215+
})
272216

273-
// This makes me sad, but if I understand correctly, this is what we have to do for reactivity to work properly with React compiler.
274217
const extendedFieldApi = useMemo(() => {
218+
// Consumers memoized on `group` (e.g. with `memo`)
219+
// need a new identity whenever a tracked slice changes
220+
void trackedState
275221
const reactiveFieldApi = {
276222
...formGroupApi,
277223
handleSubmit: ((...props: never[]) => {
@@ -280,78 +226,30 @@ export function useFormGroup<
280226
get state() {
281227
return {
282228
...formGroupApi.state,
283-
value: reactiveStateValue,
229+
get value() {
230+
trackedKeysRef.current.add('value')
231+
return formGroupApi.state.value
232+
},
284233
get meta() {
285-
return {
286-
...formGroupApi.state.meta,
287-
isTouched: reactiveMetaIsTouched,
288-
isBlurred: reactiveMetaIsBlurred,
289-
isDirty: reactiveMetaIsDirty,
290-
errorMap: reactiveMetaErrorMap,
291-
errorSourceMap: reactiveMetaErrorSourceMap,
292-
isValidating: reactiveMetaIsValidating,
293-
isSubmitting: reactiveMetaIsSubmitting,
294-
isSubmitted: reactiveMetaIsSubmitted,
295-
submissionAttempts: reactiveMetaSubmissionAttempts,
296-
isSubmitSuccessful: reactiveMetaIsSubmitSuccessful,
297-
canSubmit: reactiveMetaCanSubmit,
298-
isValid: reactiveMetaIsValid,
299-
isFieldsValid: reactiveMetaIsFieldsValid,
300-
isFieldsValidating: reactiveMetaIsFieldsValidating,
301-
isGroupValid: reactiveMetaIsGroupValid,
302-
} satisfies typeof formGroupApi.state.meta
234+
const trackedMeta = { ...formGroupApi.state.meta }
235+
for (const key of Object.keys(
236+
trackedMeta,
237+
) as (keyof typeof trackedMeta)[]) {
238+
Object.defineProperty(trackedMeta, key, {
239+
enumerable: true,
240+
get() {
241+
trackedKeysRef.current.add(key)
242+
return formGroupApi.state.meta[key]
243+
},
244+
})
245+
}
246+
return trackedMeta
303247
},
304248
} satisfies typeof formGroupApi.state
305249
},
306250
}
307-
308-
const extendedApi: FormGroupApi<
309-
TParentData,
310-
TName,
311-
TData,
312-
TOnMount,
313-
TOnChange,
314-
TOnChangeAsync,
315-
TOnBlur,
316-
TOnBlurAsync,
317-
TOnSubmit,
318-
TOnSubmitAsync,
319-
TOnDynamic,
320-
TOnDynamicAsync,
321-
TSubmitMeta,
322-
TFormOnMount,
323-
TFormOnChange,
324-
TFormOnChangeAsync,
325-
TFormOnBlur,
326-
TFormOnBlurAsync,
327-
TFormOnSubmit,
328-
TFormOnSubmitAsync,
329-
TFormOnDynamic,
330-
TFormOnDynamicAsync,
331-
TFormOnServer,
332-
TParentSubmitMeta
333-
> = reactiveFieldApi as never
334-
335-
return extendedApi
336-
}, [
337-
formGroupApi,
338-
reactiveStateValue,
339-
reactiveMetaIsTouched,
340-
reactiveMetaIsBlurred,
341-
reactiveMetaIsDirty,
342-
reactiveMetaErrorMap,
343-
reactiveMetaErrorSourceMap,
344-
reactiveMetaIsValidating,
345-
reactiveMetaIsSubmitting,
346-
reactiveMetaIsSubmitted,
347-
reactiveMetaSubmissionAttempts,
348-
reactiveMetaIsSubmitSuccessful,
349-
reactiveMetaCanSubmit,
350-
reactiveMetaIsValid,
351-
reactiveMetaIsFieldsValid,
352-
reactiveMetaIsFieldsValidating,
353-
reactiveMetaIsGroupValid,
354-
])
251+
return reactiveFieldApi as never as typeof formGroupApi
252+
}, [formGroupApi, trackedState])
355253

356254
useIsomorphicLayoutEffect(formGroupApi.mount, [formGroupApi])
357255

‎packages/preact-form/tests/useFormGroup.test.tsx‎

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -320,4 +320,172 @@ describe('form.FormGroup', () => {
320320
expect(button.disabled).toBe(false)
321321
expect(onGroupSubmit).toHaveBeenCalledTimes(1)
322322
})
323+
324+
it('should not rerender group children when a field changes and group state is not read', async () => {
325+
const renderGroupChildren = vi.fn()
326+
327+
function Comp() {
328+
const form = useForm({
329+
defaultValues: {
330+
step1: { firstName: '', lastName: '' },
331+
},
332+
})
333+
334+
return (
335+
<form.FormGroup name="step1">
336+
{() => {
337+
renderGroupChildren()
338+
return (
339+
<form.Field
340+
name="step1.firstName"
341+
children={(field) => (
342+
<input
343+
data-testid="first-name"
344+
value={field.state.value}
345+
onInput={(e) => field.handleChange(e.currentTarget.value)}
346+
/>
347+
)}
348+
/>
349+
)
350+
}}
351+
</form.FormGroup>
352+
)
353+
}
354+
355+
const { getByTestId } = render(<Comp />)
356+
const rendersAfterMount = renderGroupChildren.mock.calls.length
357+
358+
await user.type(getByTestId('first-name'), 'abc')
359+
360+
expect(getByTestId('first-name')).toHaveValue('abc')
361+
expect(renderGroupChildren).toHaveBeenCalledTimes(rendersAfterMount)
362+
})
363+
364+
it('should rerender for group meta it reads but not for unrelated value changes', async () => {
365+
const renderGroupChildren = vi.fn()
366+
367+
function Comp() {
368+
const form = useForm({
369+
defaultValues: {
370+
step1: { firstName: '' },
371+
},
372+
})
373+
374+
return (
375+
<form.FormGroup
376+
name="step1"
377+
validators={{
378+
onChange: ({ value }) =>
379+
value.firstName.includes('!')
380+
? 'No exclamation marks'
381+
: undefined,
382+
}}
383+
>
384+
{(group) => {
385+
renderGroupChildren()
386+
return (
387+
<>
388+
<form.Field
389+
name="step1.firstName"
390+
children={(field) => (
391+
<input
392+
data-testid="first-name"
393+
value={field.state.value}
394+
onInput={(e) => field.handleChange(e.currentTarget.value)}
395+
/>
396+
)}
397+
/>
398+
<pre data-testid="group-errors">
399+
{JSON.stringify(group.state.meta.errorMap)}
400+
</pre>
401+
</>
402+
)
403+
}}
404+
</form.FormGroup>
405+
)
406+
}
407+
408+
const { getByTestId } = render(<Comp />)
409+
const rendersAfterMount = renderGroupChildren.mock.calls.length
410+
411+
await user.type(getByTestId('first-name'), 'abc')
412+
expect(renderGroupChildren).toHaveBeenCalledTimes(rendersAfterMount)
413+
414+
await user.type(getByTestId('first-name'), '!')
415+
await waitFor(() =>
416+
expect(getByTestId('group-errors')).toHaveTextContent(
417+
'No exclamation marks',
418+
),
419+
)
420+
})
421+
422+
it('should read the current group value in handlers without subscribing up front', async () => {
423+
const onRead = vi.fn()
424+
425+
function Comp() {
426+
const form = useForm({
427+
defaultValues: {
428+
step1: { firstName: '' },
429+
},
430+
})
431+
432+
return (
433+
<form.FormGroup name="step1">
434+
{(group) => (
435+
<>
436+
<form.Field
437+
name="step1.firstName"
438+
children={(field) => (
439+
<input
440+
data-testid="first-name"
441+
value={field.state.value}
442+
onInput={(e) => field.handleChange(e.currentTarget.value)}
443+
/>
444+
)}
445+
/>
446+
<button
447+
data-testid="read-value"
448+
onClick={() => onRead(group.state.value)}
449+
/>
450+
</>
451+
)}
452+
</form.FormGroup>
453+
)
454+
}
455+
456+
const { getByTestId } = render(<Comp />)
457+
458+
await user.type(getByTestId('first-name'), 'abc')
459+
await user.click(getByTestId('read-value'))
460+
461+
expect(onRead).toHaveBeenCalledWith({ firstName: 'abc' })
462+
})
463+
464+
it('should expose the group store state through group.state', async () => {
465+
const onRead = vi.fn()
466+
467+
function Comp() {
468+
const form = useForm({
469+
defaultValues: {
470+
step1: { firstName: 'initial' },
471+
},
472+
})
473+
474+
return (
475+
<form.FormGroup name="step1">
476+
{(group) => (
477+
<button data-testid="read-state" onClick={() => onRead(group)} />
478+
)}
479+
</form.FormGroup>
480+
)
481+
}
482+
483+
const { getByTestId } = render(<Comp />)
484+
await user.click(getByTestId('read-state'))
485+
486+
const group = onRead.mock.calls[0]![0]
487+
expect(group.state).toEqual(group.store.state)
488+
expect(group.state.meta).toEqual(group.store.state.meta)
489+
expect(structuredClone(group.state)).toEqual(group.store.state)
490+
})
323491
})

0 commit comments

Comments
 (0)