Skip to content

Commit a45c77d

Browse files
committed
chore: add initial benchmarks
1 parent 5147c24 commit a45c77d

File tree

5 files changed

+400
-3
lines changed

5 files changed

+400
-3
lines changed
Lines changed: 273 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,273 @@
1+
/* eslint-disable @typescript-eslint/no-unnecessary-condition */
2+
import { bench, describe } from 'vitest'
3+
4+
import { z } from 'zod'
5+
import { cleanup, fireEvent, render } from '@testing-library/react'
6+
import { Formik, Field as FormikField } from 'formik'
7+
import { toFormikValidationSchema } from 'zod-formik-adapter'
8+
import { Controller, useForm as useReactHookForm } from 'react-hook-form'
9+
import { zodResolver } from '@hookform/resolvers/zod'
10+
import { ErrorMessage } from '@hookform/error-message'
11+
import { useForm as useTanStackForm } from '../src'
12+
import type { FieldProps } from 'formik/dist/Field'
13+
14+
const arr = Array.from({ length: 100 }, (_, i) => i)
15+
16+
const validators = {
17+
onChange: z.number().min(3, 'Must be at least three'),
18+
}
19+
20+
function TanStackFormOnChangeBenchmark() {
21+
const form = useTanStackForm({
22+
defaultValues: { num: arr },
23+
})
24+
25+
return (
26+
<>
27+
{arr.map((_num, i) => {
28+
return (
29+
<form.Field key={i} name={`num[${i}]`} validators={validators}>
30+
{(field) => {
31+
return (
32+
<div>
33+
<input
34+
data-testid={`value${i}`}
35+
type="number"
36+
value={field.state.value}
37+
onBlur={field.handleBlur}
38+
onChange={(e) => field.handleChange(e.target.valueAsNumber)}
39+
placeholder={`Number ${i}`}
40+
/>
41+
{field.state.meta.errors.map((error) => (
42+
<p key={error?.message}>{error?.message}</p>
43+
))}
44+
</div>
45+
)
46+
}}
47+
</form.Field>
48+
)
49+
})}
50+
</>
51+
)
52+
}
53+
54+
function FormikOnChangeBenchmark() {
55+
return (
56+
<Formik
57+
validateOnChange={true}
58+
validateOnBlur={false}
59+
validateOnMount={false}
60+
initialValues={{
61+
num: arr,
62+
}}
63+
validationSchema={toFormikValidationSchema(
64+
z.object({
65+
num: z.array(z.number().min(3, 'Must be at least three')),
66+
}),
67+
)}
68+
onSubmit={() => {}}
69+
>
70+
{() => (
71+
<>
72+
{arr.map((_num, i) => (
73+
<FormikField key={i} name={`num[${i}]`} data-testid={`value${i}`}>
74+
{(props: FieldProps) => (
75+
<div>
76+
<input
77+
data-testid={`value${i}`}
78+
type="number"
79+
name={props.field.name}
80+
value={props.field.value}
81+
onBlur={props.field.onBlur}
82+
onChange={props.field.onChange}
83+
placeholder={`Number ${i}`}
84+
/>
85+
{props.meta.error}
86+
</div>
87+
)}
88+
</FormikField>
89+
))}
90+
</>
91+
)}
92+
</Formik>
93+
)
94+
}
95+
96+
function ReactHookFormOnChangeBenchmark() {
97+
const {
98+
register,
99+
formState: { errors },
100+
} = useReactHookForm({
101+
defaultValues: {
102+
num: arr,
103+
},
104+
mode: 'onChange',
105+
resolver: zodResolver(
106+
z.object({
107+
num: z.array(z.number().min(3, 'Must be at least three')),
108+
}),
109+
),
110+
})
111+
112+
return (
113+
<>
114+
{arr.map((_num, i) => {
115+
return (
116+
<div key={i}>
117+
<input
118+
data-testid={`value${i}`}
119+
{...register(`num.${i}`, { valueAsNumber: true })}
120+
type="number"
121+
placeholder={`Number ${i}`}
122+
/>
123+
<ErrorMessage errors={errors} name={`num.${i}`} />
124+
</div>
125+
)
126+
})}
127+
</>
128+
)
129+
}
130+
131+
function ReactHookFormHeadlessOnChangeBenchmark() {
132+
const { control, handleSubmit } = useReactHookForm({
133+
defaultValues: {
134+
num: arr,
135+
},
136+
mode: 'onChange',
137+
resolver: zodResolver(
138+
z.object({
139+
num: z.array(z.number().min(3, 'Must be at least three')),
140+
}),
141+
),
142+
})
143+
144+
return (
145+
<>
146+
{arr.map((_num, i) => {
147+
return (
148+
<Controller
149+
key={i}
150+
control={control}
151+
render={({
152+
field: { value, onBlur, onChange },
153+
fieldState: { error },
154+
}) => {
155+
return (
156+
<div>
157+
<input
158+
data-testid={`value${i}`}
159+
type="number"
160+
value={value}
161+
onBlur={onBlur}
162+
onChange={(event) => onChange(event.target.valueAsNumber)}
163+
placeholder={`Number ${i}`}
164+
/>
165+
{error && <p>{error.message}</p>}
166+
</div>
167+
)
168+
}}
169+
name={`num.${i}`}
170+
/>
171+
)
172+
})}
173+
</>
174+
)
175+
}
176+
177+
describe('Validates onChange on 1,000 form items', () => {
178+
bench(
179+
'TanStack Form',
180+
async () => {
181+
const { getByTestId, findAllByText, queryAllByText } = render(
182+
<TanStackFormOnChangeBenchmark />,
183+
)
184+
185+
if (queryAllByText('Must be at least three')?.length) {
186+
throw 'Should not be present yet'
187+
}
188+
189+
fireEvent.change(getByTestId('value1'), { target: { value: 0 } })
190+
191+
await findAllByText('Must be at least three')
192+
},
193+
{
194+
setup(task) {
195+
task.opts.beforeEach = () => {
196+
cleanup()
197+
}
198+
},
199+
},
200+
)
201+
202+
bench(
203+
'Formik',
204+
async () => {
205+
const { getByTestId, findAllByText, queryAllByText } = render(
206+
<FormikOnChangeBenchmark />,
207+
)
208+
209+
if (queryAllByText('Must be at least three')?.length) {
210+
throw 'Should not be present yet'
211+
}
212+
213+
fireEvent.change(getByTestId('value1'), { target: { value: 0 } })
214+
215+
await findAllByText('Must be at least three')
216+
},
217+
{
218+
setup(task) {
219+
task.opts.beforeEach = () => {
220+
cleanup()
221+
}
222+
},
223+
},
224+
)
225+
226+
bench(
227+
'React Hook Form',
228+
async () => {
229+
const { getByTestId, findAllByText, queryAllByText } = render(
230+
<ReactHookFormOnChangeBenchmark />,
231+
)
232+
233+
if (queryAllByText('Must be at least three')?.length) {
234+
throw 'Should not be present yet'
235+
}
236+
237+
fireEvent.change(getByTestId('value1'), { target: { value: 0 } })
238+
239+
await findAllByText('Must be at least three')
240+
},
241+
{
242+
setup(task) {
243+
task.opts.beforeEach = () => {
244+
cleanup()
245+
}
246+
},
247+
},
248+
)
249+
250+
bench(
251+
'React Hook Form (Headless)',
252+
async () => {
253+
const { getByTestId, findAllByText, queryAllByText } = render(
254+
<ReactHookFormHeadlessOnChangeBenchmark />,
255+
)
256+
257+
if (queryAllByText('Must be at least three')?.length) {
258+
throw 'Should not be present yet'
259+
}
260+
261+
fireEvent.change(getByTestId('value1'), { target: { value: 0 } })
262+
263+
await findAllByText('Must be at least three')
264+
},
265+
{
266+
setup(task) {
267+
task.opts.beforeEach = () => {
268+
cleanup()
269+
}
270+
},
271+
},
272+
)
273+
})

packages/react-form/package.json

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,13 +55,19 @@
5555
"@tanstack/react-store": "https://pkg.pr.new/@tanstack/react-store@265"
5656
},
5757
"devDependencies": {
58+
"@hookform/error-message": "^2.0.1",
59+
"@hookform/resolvers": "^4.1.0",
5860
"@types/react": "^19.0.7",
5961
"@types/react-dom": "^19.0.3",
6062
"@vitejs/plugin-react": "^5.1.1",
6163
"eslint-plugin-react-compiler": "19.1.0-rc.2",
64+
"formik": "^2.4.6",
6265
"react": "^19.0.0",
6366
"react-dom": "^19.0.0",
64-
"vite": "^7.2.2"
67+
"react-hook-form": "^7.54.2",
68+
"vite": "^7.2.2",
69+
"zod": "^3.24.0",
70+
"zod-formik-adapter": "^1.3.0"
6571
},
6672
"peerDependencies": {
6773
"react": "^17.0.0 || ^18.0.0 || ^19.0.0"

packages/react-form/tsconfig.json

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,5 +7,11 @@
77
"@tanstack/form-core": ["../form-core/src"]
88
}
99
},
10-
"include": ["src", "tests", "eslint.config.js", "vite.config.ts"]
10+
"include": [
11+
"src",
12+
"tests",
13+
"benchmarks",
14+
"eslint.config.js",
15+
"vite.config.ts"
16+
]
1117
}

packages/react-form/vite.config.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ const config = defineConfig({
77
plugins: [react()],
88
test: {
99
name: packageJson.name,
10-
dir: './tests',
10+
dir: './benchmarks',
1111
watch: false,
1212
environment: 'jsdom',
1313
setupFiles: ['./tests/test-setup.ts'],

0 commit comments

Comments
 (0)