Skip to content

Commit 22d7e09

Browse files
committed
Speed up ArrayInput
## Problem The way useApplyDefaultValues and ArrayInput subscribe to the form context to check for dirty or error state force a full rerendering of all fields in a field array on each change. This is very harmful for performance. ## Solution react-hook-form v7.62.0 introduces a way to subscribe to form changes without triggering a rerender. We can therefore manage the dirty and error states by hand to avoid the rerender.
1 parent 7df8d6a commit 22d7e09

File tree

13 files changed

+182
-32
lines changed

13 files changed

+182
-32
lines changed

examples/simple/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
"react": "^18.3.1",
2626
"react-admin": "^5.11.0",
2727
"react-dom": "^18.3.1",
28-
"react-hook-form": "^7.53.0",
28+
"react-hook-form": "^7.62.0",
2929
"react-router": "^6.28.1",
3030
"react-router-dom": "^6.28.1"
3131
},

packages/ra-core/package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@
4242
"jscodeshift": "^0.15.2",
4343
"react": "^18.3.1",
4444
"react-dom": "^18.3.1",
45-
"react-hook-form": "^7.53.0",
45+
"react-hook-form": "^7.62.0",
4646
"react-router": "^6.28.1",
4747
"react-router-dom": "^6.28.1",
4848
"rimraf": "^3.0.2",
@@ -53,7 +53,7 @@
5353
"peerDependencies": {
5454
"react": "^18.0.0 || ^19.0.0",
5555
"react-dom": "^18.0.0 || ^19.0.0",
56-
"react-hook-form": "^7.53.0",
56+
"react-hook-form": "^7.62.0",
5757
"react-router": "^6.28.1 || ^7.1.1",
5858
"react-router-dom": "^6.28.1 || ^7.1.1"
5959
},

packages/ra-core/src/core/SourceContext.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ export type SourceContextValue = {
3333
export const SourceContext = createContext<SourceContextValue | undefined>(
3434
undefined
3535
);
36+
SourceContext.displayName = 'SourceContextProvider';
3637

3738
const defaultContextValue = {
3839
getSource: (source: string) => source,

packages/ra-core/src/form/groups/FormGroupsContext.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { createContext } from 'react';
33
export const FormGroupsContext = createContext<
44
FormGroupsContextValue | undefined
55
>(undefined);
6+
FormGroupsContext.displayName = 'FormGroupsContext';
67

78
export type FormGroupSubscriber = () => void;
89

packages/ra-core/src/form/useApplyInputDefaultValues.ts

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useEffect } from 'react';
1+
import { useEffect, useRef } from 'react';
22
import {
33
FieldValues,
44
UseFieldArrayReturn,
@@ -36,12 +36,21 @@ export const useApplyInputDefaultValues = ({
3636
const finalSource = useWrappedSource(source);
3737

3838
const record = useRecordContext(inputProps);
39-
const { getValues, resetField, formState, reset } = useFormContext();
39+
const { getValues, resetField, reset, subscribe } = useFormContext();
4040
const recordValue = get(record, finalSource);
4141
const formValue = get(getValues(), finalSource);
42-
const { dirtyFields } = formState;
43-
const isDirty = Object.keys(dirtyFields).includes(finalSource);
42+
const isDirty = useRef<boolean | undefined>(undefined);
4443

44+
useEffect(() => {
45+
return subscribe({
46+
formState: { dirtyFields: true },
47+
callback: ({ dirtyFields }) => {
48+
isDirty.current = Object.keys(dirtyFields ?? {}).includes(
49+
finalSource
50+
);
51+
},
52+
});
53+
}, [finalSource, subscribe]);
4554
useEffect(() => {
4655
if (
4756
defaultValue == null ||
@@ -52,7 +61,7 @@ export const useApplyInputDefaultValues = ({
5261
// We check strictly for undefined to avoid setting default value
5362
// when the field is null
5463
recordValue !== undefined ||
55-
isDirty
64+
isDirty.current === true
5665
) {
5766
return;
5867
}

packages/ra-core/src/form/useInput.stories.tsx

Lines changed: 90 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import * as React from 'react';
2+
import { useForm, FormProvider, useFieldArray } from 'react-hook-form';
23
import { CoreAdminContext } from '../core';
34
import { Form } from './Form';
45
import { InputProps, useInput } from './useInput';
@@ -7,12 +8,15 @@ export default {
78
title: 'ra-core/form/useInput',
89
};
910

10-
const Input = (props: InputProps) => {
11+
const Input = (props: InputProps & { log?: boolean }) => {
12+
const { label, log } = props;
1113
const { id, field, fieldState } = useInput(props);
12-
14+
if (log) {
15+
console.log(`Input ${id} rendered:`);
16+
}
1317
return (
1418
<label htmlFor={id}>
15-
{id}: <input id={id} {...field} />
19+
{label ?? id}: <input id={id} {...field} />
1620
{fieldState.error && <span>{fieldState.error.message}</span>}
1721
</label>
1822
);
@@ -86,3 +90,86 @@ DefaultValue.argTypes = {
8690
control: { type: 'select' },
8791
},
8892
};
93+
94+
export const Large = () => {
95+
const [submittedData, setSubmittedData] = React.useState<any>();
96+
const fields = Array.from({ length: 15 }).map((_, index) => (
97+
<Input
98+
key={index}
99+
source={`field${index + 1}`}
100+
label={`field${index + 1}`}
101+
/>
102+
));
103+
return (
104+
<CoreAdminContext>
105+
<Form
106+
onSubmit={data => setSubmittedData(data)}
107+
record={Array.from({ length: 15 }).reduce((acc, _, index) => {
108+
acc[`field${index + 1}`] = `value${index + 1}`;
109+
return acc;
110+
}, {})}
111+
>
112+
<div
113+
style={{
114+
display: 'flex',
115+
flexDirection: 'column',
116+
gap: '1em',
117+
marginBottom: '1em',
118+
}}
119+
>
120+
{fields}
121+
</div>
122+
<button type="submit">Submit</button>
123+
</Form>
124+
<pre>{JSON.stringify(submittedData, null, 2)}</pre>
125+
</CoreAdminContext>
126+
);
127+
};
128+
129+
const FieldArray = () => {
130+
const { fields } = useFieldArray({
131+
name: 'arrayField',
132+
});
133+
return (
134+
<div style={{ display: 'flex', flexDirection: 'column', gap: '1em' }}>
135+
{fields.map((field, index) => (
136+
<Input key={field.id} source={`arrayField.${index}.name`} log />
137+
))}
138+
</div>
139+
);
140+
};
141+
export const ArrayOfFields = () => {
142+
const formValue = useForm({
143+
defaultValues: {
144+
arrayField: Array.from({ length: 50 }, (_, index) => ({
145+
id: index + 1,
146+
name: `Item ${index + 1}`,
147+
})),
148+
},
149+
});
150+
const [submittedData, setSubmittedData] = React.useState<any>();
151+
return (
152+
<CoreAdminContext>
153+
<FormProvider {...formValue}>
154+
<form
155+
onSubmit={formValue.handleSubmit(data =>
156+
setSubmittedData(data)
157+
)}
158+
>
159+
<div
160+
style={{
161+
display: 'flex',
162+
flexDirection: 'column',
163+
gap: '1em',
164+
marginBottom: '1em',
165+
}}
166+
>
167+
<FieldArray />
168+
</div>
169+
<button type="submit">Submit</button>
170+
</form>
171+
</FormProvider>
172+
<pre>{JSON.stringify(submittedData, null, 2)}</pre>
173+
</CoreAdminContext>
174+
);
175+
};

packages/ra-input-rich-text/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@
5757
"ra-ui-materialui": "^5.11.0",
5858
"react": "^18.3.1",
5959
"react-dom": "^18.3.1",
60-
"react-hook-form": "^7.53.0",
60+
"react-hook-form": "^7.62.0",
6161
"rimraf": "^3.0.2",
6262
"tippy.js": "^6.3.7",
6363
"typescript": "^5.1.3"

packages/ra-ui-materialui/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@
4646
"ra-language-english": "^5.11.0",
4747
"react": "^18.3.1",
4848
"react-dom": "^18.3.1",
49-
"react-hook-form": "^7.53.0",
49+
"react-hook-form": "^7.62.0",
5050
"react-is": "^18.2.0 || ^19.0.0",
5151
"react-router": "^6.28.1",
5252
"react-router-dom": "^6.28.1",

packages/ra-ui-materialui/src/input/ArrayInput/ArrayInput.tsx

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import * as React from 'react';
2-
import { type ReactElement, useEffect } from 'react';
2+
import { type ReactElement, useEffect, useState } from 'react';
33
import clsx from 'clsx';
44
import {
55
isRequired,
@@ -24,6 +24,7 @@ import {
2424
type ComponentsOverrides,
2525
useThemeProps,
2626
} from '@mui/material';
27+
import get from 'lodash/get';
2728

2829
import { LinearProgress } from '../../layout';
2930
import type { CommonInputProps } from '../CommonInputProps';
@@ -99,13 +100,24 @@ export const ArrayInput = (inProps: ArrayInputProps) => {
99100
const formGroups = useFormGroups();
100101
const parentSourceContext = useSourceContext();
101102
const finalSource = parentSourceContext.getSource(arraySource);
103+
const [error, setError] = useState<any>();
102104

103105
const sanitizedValidate = Array.isArray(validate)
104106
? composeSyncValidators(validate)
105107
: validate;
106108
const getValidationErrorMessage = useGetValidationErrorMessage();
107109

108-
const { getFieldState, formState, getValues } = useFormContext();
110+
const { getValues, subscribe } = useFormContext();
111+
112+
useEffect(() => {
113+
return subscribe({
114+
formState: { errors: true },
115+
callback: ({ errors }) => {
116+
const error = get(errors ?? {}, finalSource);
117+
setError(error);
118+
},
119+
});
120+
}, [finalSource, subscribe]);
109121

110122
const fieldProps = useFieldArray({
111123
name: finalSource,
@@ -142,8 +154,6 @@ export const ArrayInput = (inProps: ArrayInputProps) => {
142154
fieldArrayInputControl: fieldProps,
143155
});
144156

145-
const { error } = getFieldState(finalSource, formState);
146-
147157
// The SourceContext will be read by children of ArrayInput to compute their composed source and label
148158
//
149159
// <ArrayInput source="orders" /> => SourceContext is "orders"

packages/ra-ui-materialui/src/input/ArrayInput/SimpleFormIterator.stories.tsx

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { Edit } from '../../detail';
55
import { SimpleForm } from '../../form';
66
import { ArrayInput } from './ArrayInput';
77
import { SimpleFormIterator } from './SimpleFormIterator';
8+
import { NumberInput } from '../NumberInput';
89
import { TextInput } from '../TextInput';
910
import { AdminContext } from '../../AdminContext';
1011
import { defaultTheme } from '../../theme/defaultTheme';
@@ -14,6 +15,7 @@ import {
1415
testDataProvider,
1516
useSimpleFormIteratorItem,
1617
} from 'ra-core';
18+
import { AutocompleteInput } from '../AutocompleteInput';
1719

1820
export default { title: 'ra-ui-materialui/input/SimpleFormIterator' };
1921

@@ -286,3 +288,43 @@ export const WithFormDataConsumer = () => (
286288
</ResourceContextProvider>
287289
</AdminContext>
288290
);
291+
292+
const largeDataProvider = {
293+
getOne: async () => ({
294+
data: {
295+
id: 1,
296+
name: 'Book 1',
297+
authors: Array.from({ length: 100 }, (_, i) => ({
298+
id: i + 1,
299+
first_name: `Author ${i + 1}`,
300+
last_name: `LastName ${i + 1}`,
301+
age: 30 + (i % 20),
302+
})),
303+
},
304+
}),
305+
} as any;
306+
307+
export const Large = () => (
308+
<AdminContext dataProvider={largeDataProvider} defaultTheme="light">
309+
<Edit resource="books" id="1">
310+
<SimpleForm>
311+
<TextInput source="name" />
312+
<ArrayInput source="authors">
313+
<SimpleFormIterator inline>
314+
<TextInput source="first_name" helperText={false} />
315+
<TextInput source="last_name" helperText={false} />
316+
<NumberInput source="age" helperText={false} />
317+
<AutocompleteInput
318+
source="status"
319+
choices={[
320+
{ id: 'active', name: 'Active' },
321+
{ id: 'inactive', name: 'Inactive' },
322+
]}
323+
helperText={false}
324+
/>
325+
</SimpleFormIterator>
326+
</ArrayInput>
327+
</SimpleForm>
328+
</Edit>
329+
</AdminContext>
330+
);

0 commit comments

Comments
 (0)