React Query
Let us bring in React Query
Let us look at the code with the problem highlighted:
import { useEffect, useState } from 'react';
import './App.css';
import { fetchProfile, saveProfile } from './features/profile/api';
import ProfileDisplay from './features/profile/profile-display';
import ProfileForm from './features/profile/profile-form';
import type { ProfileType } from './features/profile/types';
function App() {
const [profile, setProfile] = useState<ProfileType | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [editMode, setEditMode] = useState(false);
useEffect(() => {
fetchProfile().then((data) => {
setProfile(data);
setIsLoading(false);
});
}, []);
const handleSave = (formData: ProfileType) => {
setIsLoading(true);
saveProfile(formData).then((savedData) => {
setProfile(savedData);
setEditMode(false);
setIsLoading(false);
});
};
if (!profile || isLoading) return <p>Loading profile...</p>;
return (
<main>
<section className="section">
<h1>Profile Page</h1>
{editMode ? (
<ProfileForm initialFormData={profile} handleSave={handleSave} />
) : (
<ProfileDisplay profile={profile} setEditMode={setEditMode} />
)}
</section>
</main>
);
}
export default App;- The
fetchProfileandsaveProfilefunctions are imported from theapimodule. - But we still make raw API call in the
useEffecthook and in thehandleSavefunction. - We also have to worry about errors, loading state, etc.
- In fact, we are missing a lot of other things too like caching, retrying, etc.
Handling the above issues is really difficult, and that is where React Query comes into the picture. An industry standard library for handling data-fetching.
Install React Query
pnpm add @tanstack/react-queryNext, we have to setup the QueryClient in our main.tsx (the higher the component tree, the higher the context) file:
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import App from './App';
import './index.css';
async function enableMocking() {
if (process.env.NODE_ENV !== 'development') {
return;
}
const { worker } = await import('./mocks/browser');
return worker.start();
}
const queryClient = new QueryClient();
enableMocking().then(() => {
createRoot(document.getElementById('root')!).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
</StrictMode>,
);
});Query and Mutation
We can now use useQuery and useMutation hooks to fetch and mutate data.
useQuery
Let us log the result of the useQuery hook in the App component.
import { useQuery } from '@tanstack/react-query';
import { useEffect, useState } from 'react';
import './App.css';
import { fetchProfile, saveProfile } from './features/profile/api';
import ProfileDisplay from './features/profile/profile-display';
import ProfileForm from './features/profile/profile-form';
import type { ProfileType } from './features/profile/types';
function App() {
const { data } = useQuery({
queryKey: ['profile'],
queryFn: fetchProfile,
});
console.log(data);
const [profile, setProfile] = useState<ProfileType | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [editMode, setEditMode] = useState(false);
useEffect(() => {
fetchProfile().then((data) => {
setProfile(data);
setIsLoading(false);
});
}, []);
const handleSave = (formData: ProfileType) => {
setIsLoading(true);
saveProfile(formData).then((savedData) => {
setProfile(savedData);
setEditMode(false);
setIsLoading(false);
});
};
if (!profile || isLoading) return <p>Loading profile...</p>;
return (
<main>
<section className="section">
<h1>Profile Page</h1>
{editMode ? (
<ProfileForm initialFormData={profile} handleSave={handleSave} />
) : (
<ProfileDisplay profile={profile} setEditMode={setEditMode} />
)}
</section>
</main>
);
}
export default App;{
"firstName": "John",
"lastName": "Doe",
"age": 30,
"companyName": "OpenAI"
}So, your query works as expected. But we still have to worry about the errors, loading state, etc. But useQuery handles all of that for us.
import { useQuery } from '@tanstack/react-query';
import { useState } from 'react';
import './App.css';
import { fetchProfile, saveProfile } from './features/profile/api';
import ProfileDisplay from './features/profile/profile-display';
import ProfileForm from './features/profile/profile-form';
import type { ProfileType } from './features/profile/types';
function App() {
const {
data: profile,
isLoading,
isError,
} = useQuery({
queryKey: ['profile'],
queryFn: fetchProfile,
});
const [editMode, setEditMode] = useState(false);
const handleSave = (formData: ProfileType) => {
// setIsLoading(true);
// saveProfile(formData).then((savedData) => {
// setProfile(savedData);
// setEditMode(false);
// setIsLoading(false);
// });
};
if (isLoading) return <p>Loading profile...</p>;
if (!profile) return <p>No profile found</p>;
if (isError) return <p>Error loading profile</p>;
return (
<main>
<section className="section">
<h1>Profile Page</h1>
{editMode ? (
<ProfileForm initialFormData={profile} handleSave={handleSave} />
) : (
<ProfileDisplay profile={profile} setEditMode={setEditMode} />
)}
</section>
</main>
);
}
export default App;For now, we also comment out the handler for the handleSave function to avoid any TS errors.
Fetching is handled by the useQuery hook. What about the update? We can use the useMutation hook to handle the update.
useMutation
import { useMutation, useQuery } from '@tanstack/react-query';
import { useState } from 'react';
import './App.css';
import { fetchProfile, saveProfile } from './features/profile/api';
import ProfileDisplay from './features/profile/profile-display';
import ProfileForm from './features/profile/profile-form';
import type { ProfileType } from './features/profile/types';
function App() {
const [editMode, setEditMode] = useState(false);
const {
data: profile,
isLoading,
isError,
} = useQuery({
queryKey: ['profile'],
queryFn: fetchProfile,
});
const { mutate, isPending } = useMutation({
mutationFn: saveProfile,
onSuccess: () => {
setEditMode(false);
},
});
const handleSave = (formData: ProfileType) => {
if (isPending) return;
mutate(formData);
};
if (isLoading) return <p>Loading profile...</p>;
if (!profile) return <p>No profile found</p>;
if (isError) return <p>Error loading profile</p>;
return (
<main>
<section className="section">
<h1>Profile Page</h1>
{editMode ? (
<ProfileForm initialFormData={profile} handleSave={handleSave} />
) : (
<ProfileDisplay profile={profile} setEditMode={setEditMode} />
)}
</section>
</main>
);
}
export default App;But there is a problem, our data is not updated in the UI. But the network request is successful. This is after the mutation, we have to let queryClient know that the data has been update and it needs to be refetched. That is what the queryKey is for.
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useState } from 'react';
import './App.css';
import { fetchProfile, saveProfile } from './features/profile/api';
import ProfileDisplay from './features/profile/profile-display';
import ProfileForm from './features/profile/profile-form';
import type { ProfileType } from './features/profile/types';
function App() {
const queryClient = useQueryClient();
const [editMode, setEditMode] = useState(false);
const {
data: profile,
isLoading,
isError,
} = useQuery({
queryKey: ['profile'],
queryFn: fetchProfile,
});
const { mutate, isPending } = useMutation({
mutationFn: saveProfile,
onSuccess: () => {
setEditMode(false);
queryClient.invalidateQueries({ queryKey: ['profile'] });
},
});
const handleSave = (formData: ProfileType) => {
if (isPending) return;
mutate(formData);
};
if (isLoading) return <p>Loading profile...</p>;
if (!profile) return <p>No profile found</p>;
if (isError) return <p>Error loading profile</p>;
return (
<main>
<section className="section">
<h1>Profile Page</h1>
{editMode ? (
<ProfileForm initialFormData={profile} handleSave={handleSave} />
) : (
<ProfileDisplay profile={profile} setEditMode={setEditMode} />
)}
</section>
</main>
);
}
export default App;Remember to keep the query keys unique. Using the same key for different queries can cause unexpected behavior.
Separating Concerns
We can further separate the UI from the business logic.
We will move the useQuery and useMutation hooks to the features/profile folder. We will create new files called use-profile.ts and use-update-profile-mutation.ts in the features/profile folder. These will hold the custom hooks for fetching and updating the profile.
import { useQuery } from '@tanstack/react-query';
import { fetchProfile } from './api';
export default function useProfile() {
return useQuery({
queryKey: ['profile'],
queryFn: fetchProfile,
});
}import { useMutation, useQueryClient } from '@tanstack/react-query';
import { saveProfile } from './api';
export default function useUpdateProfileMutation({
onSuccess,
}: {
onSuccess?: () => void;
}) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: saveProfile,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['profile'] });
onSuccess?.();
},
});
}Now we can import them into App.tsx
import { useState } from 'react';
import './App.css';
import ProfileDisplay from './features/profile/profile-display';
import ProfileForm from './features/profile/profile-form';
import type { ProfileType } from './features/profile/types';
import useProfile from './features/profile/use-profile';
import useUpdateProfileMutation from './features/profile/use-update-profile-mutation';
function App() {
const [editMode, setEditMode] = useState(false);
const { data: profile, isLoading, isError } = useProfile();
const { mutate, isPending } = useUpdateProfileMutation({
onSuccess: () => {
setEditMode(false);
},
});
const handleSave = (formData: ProfileType) => {
if (isPending) return;
mutate(formData);
};
if (isLoading) return <p>Loading profile...</p>;
if (!profile) return <p>No profile found</p>;
if (isError) return <p>Error loading profile</p>;
return (
<main>
<section className="section">
<h1>Profile Page</h1>
{editMode ? (
<ProfileForm initialFormData={profile} handleSave={handleSave} />
) : (
<ProfileDisplay profile={profile} setEditMode={setEditMode} />
)}
</section>
</main>
);
}
export default App;More Clean Code
Our handleSave and the respective mutation does not need to be in the App component, we can move it to the ProfileForm component.
import { useState } from 'react';
import type { ProfileType } from './types';
import useUpdateProfileMutation from './use-update-profile-mutation';
export default function ProfileForm({
initialFormData,
onSave,
}: {
initialFormData: ProfileType;
onSave: () => void;
}) {
const { mutate, isPending } = useUpdateProfileMutation({
onSuccess: () => {
onSave();
},
});
const [formData, setFormData] = useState<ProfileType>({
...initialFormData,
});
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setFormData((prev) => ({
...prev,
[e.target.name]: e.target.value,
}));
};
const handleSave = (formData: ProfileType) => {
if (isPending) return;
mutate(formData);
};
return (
<div className="profile-form">
<label htmlFor="firstName">First Name:</label>
<input
type="text"
id="firstName"
name="firstName"
value={formData.firstName}
onChange={handleChange}
/>
<label htmlFor="lastName">Last Name:</label>
<input
type="text"
id="lastName"
name="lastName"
value={formData.lastName}
onChange={handleChange}
/>
<label htmlFor="age">Age:</label>
<input
type="number"
id="age"
name="age"
value={formData.age}
onChange={handleChange}
/>
<label htmlFor="companyName">Company:</label>
<input
type="text"
id="companyName"
name="companyName"
value={formData.companyName}
onChange={handleChange}
/>
<button disabled={isPending} onClick={() => handleSave(formData)}>
Save
</button>
</div>
);
}We also set the disabled attribute on the Save button based on the isPending state.
import { useState } from 'react';
import './App.css';
import ProfileDisplay from './features/profile/profile-display';
import ProfileForm from './features/profile/profile-form';
import useProfile from './features/profile/use-profile';
function App() {
const [editMode, setEditMode] = useState(false);
const { data: profile, isLoading, isError } = useProfile();
if (isLoading) return <p>Loading profile...</p>;
if (!profile) return <p>No profile found</p>;
if (isError) return <p>Error loading profile</p>;
const onSave = () => {
setEditMode(false);
};
const onEnableEditMode = () => {
setEditMode(true);
};
return (
<main>
<section className="section">
<h1>Profile Page</h1>
{editMode ? (
<ProfileForm initialFormData={profile} onSave={onSave} />
) : (
<ProfileDisplay
profile={profile}
onEnableEditMode={onEnableEditMode}
/>
)}
</section>
</main>
);
}
export default App;We update the ProfileDisplay component to use the onEnableEditMode prop to limit the access to the edit mode.
import type { ProfileType } from './types';
export default function ProfileDisplay({
profile,
onEnableEditMode,
}: {
profile: ProfileType;
onEnableEditMode: () => void;
}) {
return (
<div className="profile-display">
<p>
<strong>First Name:</strong> {profile.firstName}
</p>
<p>
<strong>Last Name:</strong> {profile.lastName}
</p>
<p>
<strong>Age:</strong> {profile.age}
</p>
<p>
<strong>Company:</strong> {profile.companyName}
</p>
<button onClick={onEnableEditMode}>Edit Profile</button>
</div>
);
}One final refactor
The form currently does not use a form. Let us also do some quick client side validation before saving the data.
import { useState } from 'react';
import type { ProfileType } from './types';
import useUpdateProfileMutation from './use-update-profile-mutation';
export default function ProfileForm({
initialFormData,
onSave,
}: {
initialFormData: ProfileType;
onSave: () => void;
}) {
const { mutate, isPending } = useUpdateProfileMutation({
onSuccess: () => {
onSave();
},
});
const [formData, setFormData] = useState<ProfileType>({
...initialFormData,
});
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setFormData((prev) => ({
...prev,
[e.target.name]: e.target.value,
}));
};
const handleSave = (e: React.FormEvent) => {
e.preventDefault();
mutate(formData);
};
return (
<form onSubmit={handleSave} className="profile-form">
<label htmlFor="firstName">First Name:</label>
<input
type="text"
id="firstName"
name="firstName"
required
value={formData.firstName}
onChange={handleChange}
/>
<label htmlFor="lastName">Last Name:</label>
<input
type="text"
id="lastName"
name="lastName"
required
value={formData.lastName}
onChange={handleChange}
/>
<label htmlFor="age">Age:</label>
<input
type="number"
id="age"
name="age"
required
value={formData.age}
onChange={handleChange}
/>
<label htmlFor="companyName">Company (Optional):</label>
<input
type="text"
id="companyName"
name="companyName"
value={formData.companyName}
onChange={handleChange}
/>
<button disabled={isPending} type="submit">
Save
</button>
</form>
);
}At this point, your code should be a good match to the branch of the repository: 1-solution-part-3
Last updated on