We revamped our site to better serve our users!
Profile Page

Solution Part 3

Let us bring in React Query

Let us look at the code with the problem highlighted:

src/App.tsx
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 fetchProfile and saveProfile functions are imported from the api module.
  • But we still make raw API call in the useEffect hook and in the handleSave function.
  • 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-query

Next, we have to setup the QueryClient in our main.tsx (the higher the component tree, the higher the context) file:

src/main.tsx
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.

src/App.tsx
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.

src/App.tsx
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

src/App.tsx
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.

src/App.tsx
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.

src/features/profile/use-profile.ts
import { useQuery } from '@tanstack/react-query';
import { fetchProfile } from './api';
 
export default function useProfile() {
  return useQuery({
    queryKey: ['profile'],
    queryFn: fetchProfile,
  });
}
src/features/profile/use-update-profile-mutation.ts
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

src/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.

src/features/profile/profile-form.tsx
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.

src/App.tsx
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.

src/features/profile/profile-display.tsx
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.

src/features/profile/profile-form.tsx
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

On this page