After this exercise, you will be able to:
- Identify and extract repetitive stateful logic from React components.
- Create a generic, reusable custom hook from scratch.
- Refactor multiple components to use a custom hook, making the codebase cleaner and more maintainable.
- Understand how to handle loading and error states within a custom hook.
- Implement cleanup logic in
useEffect
to prevent memory leaks.
- You will be creating a new React project from scratch for this lab.
- No repository is provided. You will be creating your own.
- Upon completion, create a new repository on GitHub.
- Add your local project to the remote repository.
- Run the following commands:
$ git add .
$ git commit -m "Solved lab"
$ git push origin main
- Share the repository link with your TAs.
For this lab, you will test your code by running the application and observing its behavior in the browser. There are no pre-written unit tests.
To run your React application, run the following command from the root of the project:
$ npm run dev
To see the outputs of console.log
in your JavaScript code, open the Console in the Developer Tools of your browser.
The goal of this exercise is to build a small React application that has duplicated data-fetching logic, and then refactor it using a custom hook. You will create a single useFetch
hook to handle all data fetching, thereby cleaning up the components and making the logic reusable.
This exercise is split into multiple iterations:
- Setup: You will create a new React project from scratch and build two components that contain duplicated logic.
- Logic: You will develop the logic for the
useFetch
custom hook. - Refactoring: You will use your new hook to refactor the two components, removing the duplicated code.
First, let's create our React project and see the problem we're trying to solve.
-
Create a React Project: Open your terminal, navigate to the directory where you want to store your project, and run the following command to create a new React project with JavaScript using Vite.
$ npm create vite@latest lab-react-custom-hooks -- --template react
When prompted, navigate into the new directory:
cd lab-react-custom-hooks
. -
Install Dependencies: We'll need
axios
for making API calls. Install it now:$ npm install axios
-
Clean Up: Open the project in VS Code. Delete the contents of
App.css
andindex.css
. Also, delete theassets
folder. We want to start fresh. -
Create Components: Inside the
src
folder, create a new folder calledcomponents
. Insidesrc/components
, create two files:PublicGists.jsx
andUserGists.jsx
. -
Build
PublicGists.jsx
: This component will fetch and display a list of public GitHub Gists. Copy the following code intosrc/components/PublicGists.jsx
:// src/components/PublicGists.jsx import { useState, useEffect } from 'react'; import axios from 'axios'; const PublicGists = () => { const [gists, setGists] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); useEffect(() => { const fetchGists = async () => { try { const response = await axios.get('https://api.github.com/gists/public'); setGists(response.data); } catch (err) { setError(err); } finally { setLoading(false); } }; fetchGists(); }, []); // Empty dependency array means this runs once on mount if (loading) return <p>Loading public gists...</p>; if (error) return <p>Error fetching gists: {error.message}</p>; return ( <div> <h2>Public Gists</h2> <ul> {gists.map(gist => ( <li key={gist.id}> <a href={gist.html_url} target="_blank" rel="noopener noreferrer"> {gist.description || 'No description'} </a> </li> ))} </ul> </div> ); }; export default PublicGists;
-
Build
UserGists.jsx
: This component does almost the same thing, but fetches Gists for a specific user. Notice how similar the logic is. This is the code duplication we want to fix. Copy this code intosrc/components/UserGists.jsx
:// src/components/UserGists.jsx import { useState, useEffect } from 'react'; import axios from 'axios'; const UserGists = () => { const [gists, setGists] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const username = 'gaearon'; // A famous React developer! useEffect(() => { const fetchGists = async () => { try { const response = await axios.get(`https://api.github.com/users/${username}/gists`); setGists(response.data); } catch (err) { setError(err); } finally { setLoading(false); } }; fetchGists(); }, []); // The username is hardcoded, so we still run once if (loading) return <p>Loading {username}'s gists...</p>; if (error) return <p>Error fetching gists: {error.message}</p>; return ( <div> <h2>{username}'s Gists</h2> <ul> {gists.map(gist => ( <li key={gist.id}> <a href={gist.html_url} target="_blank" rel="noopener noreferrer"> {gist.description || 'No description'} </a> </li> ))} </ul> </div> ); }; export default UserGists;
-
Update
App.jsx
: Finally, let's render these two components in our mainApp
component. Replace the content ofsrc/App.jsx
with this:// src/App.jsx import PublicGists from './components/PublicGists'; import UserGists from './components/UserGists'; function App() { return ( <div> <h1>React Custom Hooks Lab</h1> <PublicGists /> <hr /> <UserGists /> </div> ); } export default App;
-
Run the App: Go to your terminal and run
npm run dev
. Open your browser to the specified URL. You should see both lists of Gists load. Take a moment to appreciate the duplicated logic in both component files. Our mission is to eliminate it!
Now for the fun part! Let's extract the duplicated logic into a reusable custom hook.
Tip
A custom hook is just a JavaScript function whose name starts with use
and that can call other hooks. It's a fundamental pattern for building scalable React apps and following the DRY (Don't Repeat Yourself) principle.
- Create the file: Inside the
src
folder, create a new folder namedhooks
. Insidesrc/hooks
, create a new file nameduseFetch.js
. - Define the function: Export a function named
useFetch
. It should accept one argument:url
(a string). - Manage State: Inside the hook, declare three state variables using
useState
:data
: To store the fetched data. Initialize it asnull
.loading
: To track the loading state. Initialize it astrue
.error
: To store any potential error. Initialize it asnull
.
- Fetch Data: Use the
useEffect
hook to perform the data fetching.- The effect should run whenever the
url
prop changes. - Inside the effect, define an
async
function to fetch data from the providedurl
usingaxios
. - Handle the success case: set the fetched data into the
data
state and seterror
tonull
. - Handle the error case: catch any errors, store them in the
error
state, and setdata
tonull
. - Use a
finally
block to setloading
tofalse
after the fetch attempt is complete (whether it succeeded or failed).
- The effect should run whenever the
- Return Values: The hook should return an object containing the three state variables:
{ data, loading, error }
.
Click for Solution
// src/hooks/useFetch.js
import { useState, useEffect } from 'react';
import axios from 'axios';
export function useFetch(url) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
const fetchData = async () => {
try {
setLoading(true);
const response = await axios.get(url);
setData(response.data);
setError(null);
} catch (err) {
setError(err);
setData(null);
} finally {
setLoading(false);
}
};
fetchData();
}, [url]); // Re-run the effect if the URL changes
return { data, loading, error };
}
With our useFetch
hook ready, let's put it to work. For this iteration, you'll be working in src/components/PublicGists.jsx
.
Your goal is to remove all the manual useState
and useEffect
logic for data fetching and replace it with a single call to your new useFetch
hook.
-
Import the hook: At the top of the file, import your
useFetch
hook:import { useFetch } from '../hooks/useFetch';
. -
Call the hook: Inside the
PublicGists
component, call your hook with the public gists API URL.const { data: gists, loading, error } = useFetch('https://api.github.com/gists/public');
[!NOTE] We are renaming
data
togists
using destructuring assignment (data: gists
). This makes the component's code more readable. -
Remove old code: Delete the
useState
calls forgists
,loading
, anderror
, and also delete the entireuseEffect
block that was performing the fetch. You can also remove theuseState
anduseEffect
imports from React.
Your component should now be much shorter and cleaner. It is now only responsible for displaying the UI based on the state provided by the hook, not managing the fetching logic itself. This is called Separation of Concerns.
Check your application in the browser. It should still work exactly as before.
To see the true power of reusability, let's refactor the second component. You'll be working in src/components/UserGists.jsx
.
-
Import the hook:
import { useFetch } from '../hooks/useFetch';
. -
Call the hook: Just like before, replace the
useState
anduseEffect
blocks with a single call touseFetch
. This time, use the user-specific API endpoint.const username = 'gaearon'; // A famous React developer! const { data: gists, loading, error } = useFetch(`https://api.github.com/users/${username}/gists`);
-
Remove old code: Delete the now-redundant
useState
anduseEffect
logic and their imports.
You have now refactored two components using the same hook, removing a significant amount of duplicated code. If you ever need to change how data is fetched (e.g., add authentication headers), you only need to change it in one place: useFetch.js
.
What happens if a component unmounts while a fetch request is still in progress? React will show a warning in the console about trying to update the state of an unmounted component. This can lead to memory leaks.
Caution
Forgetting to clean up side effects from useEffect
is a common source of bugs in React applications. Always consider what should happen if the component unmounts.
Let's make our hook more robust by adding cleanup logic. For this iteration, you'll be working in src/hooks/useFetch.js
again.
- Create an AbortController: Inside the
useEffect
hook (but before yourfetchData
function), create a newAbortController
. - Pass the signal to Axios: In your
axios.get
call, pass the controller'ssignal
in the options object. - Return a cleanup function: At the end of your
useEffect
, return a cleanup function. This function will be called when the component unmounts or when theurl
changes. Inside this function, callcontroller.abort()
. - Handle AbortError: When a request is aborted, Axios throws an error. We don't want to treat this as a real error in our UI. Update your
catch
block to ignore cancellation errors.
Click for Solution
// src/hooks/useFetch.js
import { useState, useEffect } from 'react';
import axios from 'axios';
export function useFetch(url) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
const controller = new AbortController(); // 1. Create AbortController
const fetchData = async () => {
try {
setLoading(true);
// 2. Pass signal to axios
const response = await axios.get(url, { signal: controller.signal });
setData(response.data);
setError(null);
} catch (err) {
// 4. Handle cancellation error
if (axios.isCancel(err)) {
console.log('Request canceled:', err.message);
return;
}
setError(err);
setData(null);
} finally {
setLoading(false);
}
};
fetchData();
// 3. Return cleanup function
return () => {
controller.abort();
};
}, [url]);
return { data, loading, error };
}
In many UIs, especially dashboards, sidebars, or FAQs, you'll want to toggle the visibility of content—commonly known as an accordion pattern. Instead of managing isOpen
state manually in every component, let's abstract the logic into a reusable custom hook called useAccordion
.
Tip
This is a great example of how custom hooks promote DRY code and improve maintainability across multiple components.
You’ll implement this in src/hooks/useAccordion.js
.
- Create the hook structure: Define a hook named
useAccordion
that internally manages a boolean state (isOpen
). - Add toggle behavior: Expose a
toggle
function that flips the state. - Expose helper controls: Include optional
open()
andclose()
methods for extra control. - Return a simple API: The hook should return
{ isOpen, toggle, open, close }
.
Click for Solution
// src/hooks/useAccordion.js
import { useState, useCallback } from 'react';
export function useAccordion(initialState = false) {
const [isOpen, setIsOpen] = useState(initialState);
const toggle = useCallback(() => setIsOpen(prev => !prev), []);
const open = useCallback(() => setIsOpen(true), []);
const close = useCallback(() => setIsOpen(false), []);
return { isOpen, toggle, open, close };
}
// src/components/FAQ.jsx
import { useAccordion } from '../hooks/useAccordion';
export function FAQItem() {
const { isOpen, toggle } = useAccordion();
return (
<div>
<button onClick={toggle} aria-expanded={isOpen}>
{isOpen ? 'Hide Answer' : 'Show Answer'}
</button>
{isOpen && <p>This is the answer to the question.</p>}
</div>
);
}
[!IMPORTANT] A well-designed custom hook should return only the logic and values needed for a component—not JSX or UI.
This pattern makes your UI code cleaner and separates concerns between UI rendering and state management.
Your useFetch
hook is now production-ready! It's generic, reusable, and safely handles side effects.
Happy coding! ❤️
- Official React Docs on Custom Hooks: The best place to start for a deep dive into the concept. Read it here.
- react-use: An excellent open-source library full of useful, production-ready custom hooks. It's a great place to see more examples and get inspiration. Check it out on GitHub.
- Axios Cancellation Docs: Learn more about how to cancel requests with Axios. Read the docs.