React Query and Forms
— ReactJs, React Query, TypeScript, JavaScript — 5 min read
- #1: Practical React Query
- #2: React Query Data Transformations
- #3: React Query Render Optimizations
- #4: Status Checks in React Query
- #5: Testing React Query
- #6: React Query and TypeScript
- #7: Using WebSockets with React Query
- #8: Effective React Query Keys
- #8a: Leveraging the Query Function Context
- #9: Placeholder and Initial Data in React Query
- #10: React Query as a State Manager
- #11: React Query Error Handling
- #12: Mastering Mutations in React Query
- #13: Offline React Query
- #14: React Query and Forms
- #15: React Query FAQs
- #16: React Query meets React Router
- #17: Seeding the Query Cache
- #18: Inside React Query
- #19: Type-safe React Query
- #20: You Might Not Need React Query
- #21: Thinking in React Query
- #22: React Query and React Context
- #23: Why You Want React Query
- #24: The Query Options API
- #25: Automatic Query Invalidation after Mutations
- #26: How Infinite Queries work
- #27: React Query API Design - Lessons Learned
Forms are an important part in many web applications as the primary means to update data. We are using React Query not only to fetch data (queries), but also to modify it (mutations), so we need to somehow integrate our beloved async state manager with forms.
The good news is that realistically, there isn't anything special about forms: It is still just a bunch of html elements that we render in order to display some data. However, as we'd also like to change that data, the lines between what is Server State and what is Client State start to blur a bit, which is where the complexity might come in.
Server State vs. Client State
To recap, Server State is state that we do not own, that is mostly async and where we only see a snapshot of how the data looked like the last time we fetched it.
Client State is state that the frontend has full control over, is mostly synchronous and where we know the accurate value of it at all times.
When we display a list of Persons, that is undoubtedly Server State. But what happens when we click on a Person to show their details in a Form with the intention of maybe updating some values? Does that Server State now become Client State? Is it a hybrid?
The simple approach
I have gone on the record already about how I am not a fan of copying state from one state manager to another, be it putting props to state or copying state from React Query to local state.
I do think that forms can be an exception to this rule though, if you are doing it deliberately and know about the tradeoffs (everything is a tradeoff after all). When rendering our Person form, we very likely want to treat the Server State as initial data only. We fetch the firstName and lastName, put it into the form state, and then let the user update it.
Let's take a look at an example:
1function PersonDetail({ id }) {2 const { data } = useQuery({3 queryKey: ['person', id],4 queryFn: () => fetchPerson(id),5 })6 const { register, handleSubmit } = useForm()7 const { mutate } = useMutation({8 mutationFn: (values) => updatePerson(values),9 })10
11 if (data) {12 return (13 <form onSubmit={handleSubmit(mutate)}>14 <div>15 <label htmlFor="firstName">First Name</label>16 <input17 {...register('firstName')}18 defaultValue={data.firstName}19 />20 </div>21 <div>22 <label htmlFor="lastName">Last Name</label>23 <input24 {...register('lastName')}25 defaultValue={data.lastName}26 />27 </div>28 <input type="submit" />29 </form>30 )31 }32
33 return 'loading...'34}
This works incredibly well - so what are those tradeoffs?
Data might be undefined
You might know that useForm
would also take defaultValues directly for the whole form, which would be pretty nice for larger forms. However, because we cannot call hooks conditionally, and because our data
is undefined
on the first render cycle (as we need to fetch it first), we cannot just do this in the same component:
1const { data } = useQuery({2 queryKey: ['person', id],3 queryFn: () => fetchPerson(id),4})5// 🚨 this will initialize our form with undefined6const { register, handleSubmit } = useForm({ defaultValues: data })
We'd have the same problem when copying into useState
, or when using uncontrolled forms (which react-hook-form does under the hood by the way). The best solution to this would be to split up the form into its own component:
1function PersonDetail({ id }) {2 const { data } = useQuery({3 queryKey: ['person', id],4 queryFn: () => fetchPerson(id),5 })6 const { mutate } = useMutation({7 mutationFn: (values) => updatePerson(values),8 })9
10 if (data) {11 return <PersonForm person={data} onSubmit={mutate} />12 }13
14 return 'loading...'15}16
17function PersonForm({ person, onSubmit }) {18 const { register, handleSubmit } = useForm({ defaultValues: person })19 return (20 <form onSubmit={handleSubmit(onSubmit)}>21 <div>22 <label htmlFor="firstName">First Name</label>23 <input {...register('firstName')} />24 </div>25 <div>26 <label htmlFor="lastName">Last Name</label>27 <input {...register('lastName')} />28 </div>29 <input type="submit" />30 </form>31 )32}
This is not too bad, as it separates our data fetching from the presentation. I'm personally not a big fan of such a split, but it does get the job done here.
No background updates
React Query is all about keeping your UI up-to-date with Server State. As soon as we copy that state somewhere else, React Query cannot do its job anymore. if a background refetch happens for whatever reason, and it yields new data, our form state will not update with it. This is likely not problematic if we are the only one working on that form state (like a form for our profile page). If that's the case, we should likely at least disable background updates by setting a higher staleTime
on our query. After all, why would we keep querying our server if the updates will not be reflected on the screen?
1// ✅ opt out of background updates2const { data } = useQuery({3 queryKey: ['person', id],4 queryFn: () => fetchPerson(id),5 staleTime: Infinity,6})
This approach can get problematic on bigger forms and in collaborative environments. The bigger the form, the longer it takes our users to fill it out. If multiple people work on the same form, but on different fields, whoever updates last might override the values that others have changed, because they still see a partially outdated version on their screen.
Now react hook form allows you to detect which fields have been changed by the user and only send "dirty" fields to the server with some user land code (see the example here), which is pretty cool. However, this still doesn't show the latest values with updates made by other users to you. Maybe you would change your input had you known that a certain field was changed in the meantime by someone else.
So what would we need to do to still reflect background updates while we are editing our form?
Keeping background updates on
One approach is to rigorously separate the states. We'll keep the Server State in React Query, and only track the changes the user has made with our Client State. The source of truth that we display then to our users is derived state from those two: If the user has changed a field, we show the Client State. If not, we fall back to the Server State:
1function PersonDetail({ id }) {2 const { data } = useQuery({3 queryKey: ['person', id],4 queryFn: () => fetchPerson(id),5 })6 const { control, handleSubmit } = useForm()7 const { mutate } = useMutation({8 mutationFn: (values) => updatePerson(values),9 })10
11 if (data) {12 return (13 <form onSubmit={handleSubmit(mutate)}>14 <div>15 <label htmlFor="firstName">First Name</label>16 <Controller17 name="firstName"18 control={control}19 render={({ field }) => (20 // ✅ derive state from field value (client state)21 // and data (server state)22 <input23 {...field}24 value={field.value ?? data.firstName}25 />26 )}27 />28 </div>29 <div>30 <label htmlFor="lastName">Last Name</label>31 <Controller32 name="lastName"33 control={control}34 render={({ field }) => (35 <input36 {...field}37 value={field.value ?? data.lastName}38 />39 )}40 />41 </div>42 <input type="submit" />43 </form>44 )45 }46
47 return 'loading...'48}
With that approach, we can keep background updates on, because it will still be relevant for untouched fields. We are no longer bound to the initialState that we had when we first rendered the form. As always, there are caveats here as well:
You need controlled fields
As far as I'm aware, there is no good way to achieve this with uncontrolled fields, which is why I've resorted to using controlled fields in the above example. Please let me know if I'm missing something.
Deriving state might be difficult
This approach works best for shallow forms, where you can easily fall back to the Server State using nullish coalesce, but it could be more difficult to merge properly with nested objects. It might also sometimes be a questionable user experience to just change form values in the background. A better idea might be to just highlight values that are out of sync with the Server State and let the user decide what to do.
Whichever way you choose, try to be aware of the advantages / disadvantages that each approach brings.
Tips and Tricks
Apart from those two principal ways of setting up your form, here are some smaller, but nonetheless important tricks to integrate React Query with forms:
Double submit prevention
To prevent a form from being submitted twice, you can use the isLoading
prop returned from useMutation
, as it will be true for as long as our mutation is running. To disable the form itself, all you need to do is to disable the primary submit button:
1const { mutate, isLoading } = useMutation({2 mutationFn: (values) => updatePerson(values)3})4<input type="submit" disabled={isLoading} />
Invalidate and reset after mutation
If you do not redirect to a different page right after the form submission, it might be a good idea to reset the form after the invalidation has completed. As described in Mastering Mutations, you'd likely want to do that in the onSuccess
callback of mutate
. This also works best if you keep state seperated, as you only need to reset to undefined
in order for the server state to be picked up again:
1function PersonDetail({ id }) {2 const queryClient = useQueryClient()3 const { data } = useQuery({4 queryKey: ['person', id],5 queryFn: () => fetchPerson(id),6 })7 const { control, handleSubmit, reset } = useForm()8 const { mutate } = useMutation({9 mutationFn: updatePerson,10 // ✅ return Promise from invalidation11 // so that it will be awaited12 onSuccess: () =>13 queryClient.invalidateQueries({ queryKey: ['person', id] }),14 })15
16 if (data) {17 return (18 <form19 onSubmit={handleSubmit((values) =>20 // ✅ reset client state back to undefined21 mutate(values, { onSuccess: () => reset() })22 )}23 >24 <div>25 <label htmlFor="firstName">First Name</label>26 <Controller27 name="firstName"28 control={control}29 render={({ field }) => (30 <input31 {...field}32 value={field.value ?? data.firstName}33 />34 )}35 />36 </div>37 <input type="submit" />38 </form>39 )40 }41
42 return 'loading...'43}
That's it for today. Feel free to reach out to me on bluesky if you have any questions, or just leave a comment below. ⬇️