Table of Contents Introduction Why asynchronous JavaScript matters in React Understanding Callbacks The original way to handle async logic How they work Callback hell explained From Callbacks to Promises A cleaner way to handle async code What is a Promise? Chaining with.then() Catching errors Async/Await Simplified Writing async code that reads like sync How async/await works Error handling withtry/catch When to use it Using Async Logic Inside React Components Common mistakes to avoid Handling side effects withuseEffect Best practices for fetching data Real-World Examples API request with fetch() Loading states and error handling Cleaning up side effects Key Takeaways What to remember and apply today Final Thoughts Writing clean async logic in React doesn’t have to be hard Introduction Why asynchronous JavaScript matters in React Why asynchronous JavaScript matters in React Why asynchronous JavaScript matters in React Understanding Callbacks The original way to handle async logic How they work Callback hell explained The original way to handle async logic How they work Callback hell explained The original way to handle async logic How they work Callback hell explained From Callbacks to Promises A cleaner way to handle async code What is a Promise? Chaining with.then() Catching errors A cleaner way to handle async code What is a Promise? Chaining with.then() Catching errors A cleaner way to handle async code What is a Promise? Chaining with.then() .then() Catching errors Async/Await Simplified Writing async code that reads like sync How async/await works Error handling withtry/catch When to use it Writing async code that reads like sync How async/await works Error handling withtry/catch When to use it Writing async code that reads like sync How async/await works Error handling withtry/catch try/catch When to use it Using Async Logic Inside React Components Common mistakes to avoid Handling side effects withuseEffect Best practices for fetching data Common mistakes to avoid Handling side effects withuseEffect Best practices for fetching data Common mistakes to avoid Handling side effects withuseEffect useEffect Best practices for fetching data Real-World Examples API request with fetch() Loading states and error handling Cleaning up side effects API request with fetch() Loading states and error handling Cleaning up side effects API request with fetch() fetch() Loading states and error handling Cleaning up side effects Key Takeaways What to remember and apply today What to remember and apply today What to remember and apply today Final Thoughts Writing clean async logic in React doesn’t have to be hard Writing clean async logic in React doesn’t have to be hard Writing clean async logic in React doesn’t have to be hard Introduction Why Asynchronous JavaScript Matters in React Why Asynchronous JavaScript Matters in React React is fast. But fast doesn’t always mean efficient—especially when it comes to real-world data. Most React apps aren't just static pages. They fetch data from APIs, wait for user input, or communicate with servers. That’s where asynchronous JavaScript comes in. asynchronous JavaScript Imagine this: You build a blog app. It fetches posts from an API. But that API might take 1 second, or 10. If you don’t handle that properly, your UI will freeze. Your users will get confused. Your app feels broken. And the worst part?React won’t fix that for you. React renders your UI, but it’s your job to make sure it behaves well during delays. renders your UI your job So what’s the fix? You make your logic asynchronous—and that means using: asynchronous Promises async/await fetch() maybe even axios Promises Promises async/await async/await fetch() fetch() maybe even axios axios Let’s look at a small example. A Simple Example You want to load user data from an API when the component mounts. Here’s a basic version of how that works: import { useEffect, useState } from "react"; function UserProfile() { const [user, setUser] = useState(null); const [loading, setLoading] = useState(true); useEffect(() => { async function fetchUser() { try { const res = await fetch("https://api.example.com/user"); const data = await res.json(); setUser(data); } catch (err) { console.error("Failed to fetch user:", err); } finally { setLoading(false); } } fetchUser(); }, []); if (loading) return <p>Loading...</p>; return ( <div> <h1>{user.name}</h1> <p>Email: {user.email}</p> </div> ); } import { useEffect, useState } from "react"; function UserProfile() { const [user, setUser] = useState(null); const [loading, setLoading] = useState(true); useEffect(() => { async function fetchUser() { try { const res = await fetch("https://api.example.com/user"); const data = await res.json(); setUser(data); } catch (err) { console.error("Failed to fetch user:", err); } finally { setLoading(false); } } fetchUser(); }, []); if (loading) return <p>Loading...</p>; return ( <div> <h1>{user.name}</h1> <p>Email: {user.email}</p> </div> ); } What’s Happening Here? We use useEffect() to run a function when the component mounts. Inside it, we define fetchUser()—an async function. We call fetch() to get the data. We update the state once it arrives. We show a loading state while we wait. We use useEffect() to run a function when the component mounts. useEffect() Inside it, we define fetchUser()—an async function. fetchUser() async function We call fetch() to get the data. fetch() We update the state once it arrives. We show a loading state while we wait. loading state Simple idea.But this one pattern powers 90% of real-world React apps. Why Does This Matter? Without async logic, this component would: Crash if the API is slow. Freeze the UI during the fetch. Never show loading feedback. Fail silently on network errors. Crash if the API is slow. Freeze the UI during the fetch. Never show loading feedback. Fail silently on network errors. In short: It wouldn’t work. It wouldn’t work Asynchronous JavaScript is what turns React from a UI library into a responsive, user-friendly application. It’s not just a detail.It’s the difference between a broken app and a smooth one. Understanding Callbacks The original way to handle async logic Before Promises.Beforeasync/await.There were callbacks. async/await They were the first way JavaScript developers handled asynchronous logic like file reading, database access, or API requests. But what are callbacks, really? How Callbacks Work A callback is just a function passed into another function, to be called back later. callback called back Let’s break it down. function fetchData(callback) { setTimeout(() => { const data = { name: 'Alice', age: 25 }; callback(data); // calling back }, 1000); } function handleData(data) { console.log('Received:', data); } fetchData(handleData); function fetchData(callback) { setTimeout(() => { const data = { name: 'Alice', age: 25 }; callback(data); // calling back }, 1000); } function handleData(data) { console.log('Received:', data); } fetchData(handleData); Here’s what’s happening: We simulate a delay using setTimeout (like a fake API call). Once the delay ends, we execute the callback function with the data. handleData is our callback. It's passed in, and it’s run later. We simulate a delay using setTimeout (like a fake API call). setTimeout Once the delay ends, we execute the callback function with the data. callback handleData is our callback. It's passed in, and it’s run later. handleData This pattern lets you control what happens after the async work is done. what happens after Simple enough, right? But Then Came “Callback Hell” Callbacks work.But they don’t scale well. What happens when you need one async call…after another…after another? loginUser('john', 'secret', function(user) { getUserProfile(user.id, function(profile) { getRecentPosts(profile.id, function(posts) { sendAnalytics(posts, function(result) { console.log('All done!'); }); }); }); }); loginUser('john', 'secret', function(user) { getUserProfile(user.id, function(profile) { getRecentPosts(profile.id, function(posts) { sendAnalytics(posts, function(result) { console.log('All done!'); }); }); }); }); This is called callback hell. callback hell Also known as: Pyramid of doom Christmas tree structure Pyramid of doom Christmas tree structure It’s hard to read.Harder to debug.And when something breaks… good luck tracing the issue. Why It Happens Callbacks nest because each one depends on the previous one finishing.And since each call is async, you can't just write it top-to-bottom like synchronous code. This leads to:→ deeply nested functions→ hard-to-manage error handling→ unclear control flow The Real Problem It’s not that callbacks are broken.It’s that they don’t handlecomposition well. composition They make it hard to:→ sequence actions→ handle errors in one place→ reuse logic That’s why Promises came in.Thenasync/await improved it even more. async/await But it all started here. Callbacks were step onein solving async logic.Simple, but painful at scale.Useful, but messy when chained. Callbacks were step one Before jumping into Promises or async/await, understand how it all started. async/await Because even today, callbacks still exist under the hood.Understanding them helps you debug, write cleaner code, and know what your tools are doing for you. Want to go deeper?Try rewriting the nested callback above using Promises.It’s a good way to see the difference clearly. From Callbacks to Promises A cleaner way to handle async code JavaScript was built with a non-blocking nature. But that came at a cost: callbacks. If you've ever written something like this… getUser(id, function(user) { getPosts(user.id, function(posts) { getComments(posts[0].id, function(comments) { // do something with comments }); }); }); getUser(id, function(user) { getPosts(user.id, function(posts) { getComments(posts[0].id, function(comments) { // do something with comments }); }); }); You already know the pain. This pattern is called callback hell — deeply nested functions that are hard to read, hard to manage, and harder to debug. callback hell So developers asked for a better way. And that better way came in the form of Promises. Promises → What is a Promise? A Promise is a placeholder for a value that might not be available yet — but will be in the future. placeholder for a value It has three possible states: pending: the operation is still happening fulfilled: the operation completed successfully rejected: something went wrong pending: the operation is still happening pending fulfilled: the operation completed successfully fulfilled rejected: something went wrong rejected Here’s what a basic promise looks like: const promise = new Promise((resolve, reject) => { setTimeout(() => { resolve('Data loaded'); }, 1000); }); promise.then(data => { console.log(data); // "Data loaded" }); const promise = new Promise((resolve, reject) => { setTimeout(() => { resolve('Data loaded'); }, 1000); }); promise.then(data => { console.log(data); // "Data loaded" }); → Chaining with .then() .then() One of the biggest advantages of Promises is chaining. chaining Instead of nesting functions like callbacks, you can just return values in a .then() block and pass it along the chain. .then() fetchUser(1) .then(user => fetchPosts(user.id)) .then(posts => fetchComments(posts[0].id)) .then(comments => { console.log('Comments:', comments); }); fetchUser(1) .then(user => fetchPosts(user.id)) .then(posts => fetchComments(posts[0].id)) .then(comments => { console.log('Comments:', comments); }); Each .then() waits for the one before it to finish. .then() This flattens the structure and keeps things readable. → Catching errors Errors happen. That’s a given in async code. Instead of wrapping each callback in a try-catch or checking error values manually, Promises give you .catch(). .catch() It catches any error that occurs in the chain. fetchUser(1) .then(user => fetchPosts(user.id)) .then(posts => fetchComments(posts[0].id)) .then(comments => { console.log('Comments:', comments); }) .catch(error => { console.error('Something went wrong:', error); }); fetchUser(1) .then(user => fetchPosts(user.id)) .then(posts => fetchComments(posts[0].id)) .then(comments => { console.log('Comments:', comments); }) .catch(error => { console.error('Something went wrong:', error); }); If any step fails, the chain jumps directly to .catch(). .catch() No need to clutter your logic with conditionals at every step. Why this matters Callbacks worked — but Promises made things cleaner, readable, and easier to debug. They also laid the groundwork for async/await, which we’ll cover in another post. async/await But if you’re still using nested callbacks in 2025 — you’re missing out on simpler, more maintainable code. Clean code is not just about how it works. It’s about how it reads. Async/Await Simplified Write async code that reads like sync Write async code that reads like sync Let’s be honest.Most people don’treally understand async/await. really They useit.Theycopyit.But they don’tget it. use copy get So let’s break it down. First, what is async/await? Before async/await, we had this: async/await fetchData() .then(res => process(res)) .catch(err => handle(err)) fetchData() .then(res => process(res)) .catch(err => handle(err)) It worked. But chains get messy.Now imagine three, four, five async calls in a row. Now it looks like this: async function run() { try { const data = await fetchData() const result = await process(data) console.log(result) } catch (err) { handle(err) } } async function run() { try { const data = await fetchData() const result = await process(data) console.log(result) } catch (err) { handle(err) } } Feels like normal code, right?That’s the point. How does it work? Let’s break this line down: const data = await fetchData() const data = await fetchData() Here’s what happens: fetchData() returns a Promise. await pauses the function until the Promise resolves. The result is stored in data. fetchData() returns a Promise. fetchData() await pauses the function until the Promise resolves. await until The result is stored in data. data It doesn’t blockthe entire app.It only pausesthis function.Other things keep running in the background. doesn’t block this function This only works inside an asyncfunction.Try usingawait without it, and JavaScript will complain. inside async await What about error handling? That’s where try/catch shines. try/catch Let’s look at this: async function loadUserProfile(id) { try { const user = await fetchUser(id) const posts = await fetchPosts(user.id) return { user, posts } } catch (error) { console.error('Something went wrong:', error.message) throw error } } async function loadUserProfile(id) { try { const user = await fetchUser(id) const posts = await fetchPosts(user.id) return { user, posts } } catch (error) { console.error('Something went wrong:', error.message) throw error } } Simple.Readable.Clean. No more nested .then().catch().Just normaltry and catch. .then().catch() try catch So when should you use async/await? Use it when:→ You’re making one or more async calls→ You want the code to be easy to read→ You care about handling errors clearly Don’t use it for everything. If you need to do multiple things at once, use Promise.all: at once Promise.all const [user, posts] = await Promise.all([ fetchUser(id), fetchPosts(id) ]) const [user, posts] = await Promise.all([ fetchUser(id), fetchPosts(id) ]) This runs both at the same time.And still looks clean. Final thoughts Async/await doesn’t make things faster.It just makes them easier to read. And easier to debug. So next time you're writing async code…Write it like sync.Useasync. Use await.And don’t forget thetry/catch. async await try/catch Clean code wins. Using Async Logic Inside React Components Async operations like fetching data or reading from storage are a common part of building React apps. But handling them inside React components, especially with hooks like useEffect, can easily lead to bugs or bad user experiences if not done right. useEffect This post breaks down how to use async logic correctly in React components, the common mistakes to avoid, and best practices for fetching data. ❌ Common Mistakes to Avoid 1. Using async directly in useEffect Using async directly in useEffect You might try this: useEffect(async () => { const res = await fetch('/api/data'); const data = await res.json(); setData(data); }, []); useEffect(async () => { const res = await fetch('/api/data'); const data = await res.json(); setData(data); }, []); But this won’t work. React expects the function passed to useEffect to return either undefined or a cleanup function — not a Promise. useEffect undefined Fix: Define the async function inside and call it: Fix useEffect(() => { const fetchData = async () => { const res = await fetch('/api/data'); const data = await res.json(); setData(data); }; fetchData(); }, []); useEffect(() => { const fetchData = async () => { const res = await fetch('/api/data'); const data = await res.json(); setData(data); }; fetchData(); }, []); 2. Not cleaning up async calls when a component unmounts Not cleaning up async calls when a component unmounts If the component unmounts before the async function completes, it may try to update state on an unmounted component. This causes a warning or memory leak. useEffect(() => { let isMounted = true; const fetchData = async () => { const res = await fetch('/api/data'); const data = await res.json(); if (isMounted) { setData(data); } }; fetchData(); return () => { isMounted = false; }; }, []); useEffect(() => { let isMounted = true; const fetchData = async () => { const res = await fetch('/api/data'); const data = await res.json(); if (isMounted) { setData(data); } }; fetchData(); return () => { isMounted = false; }; }, []); Or use an AbortController (better for fetch): AbortController useEffect(() => { const controller = new AbortController(); const fetchData = async () => { try { const res = await fetch('/api/data', { signal: controller.signal }); const data = await res.json(); setData(data); } catch (err) { if (err.name !== 'AbortError') { console.error('Fetch failed:', err); } } }; fetchData(); return () => controller.abort(); }, []); useEffect(() => { const controller = new AbortController(); const fetchData = async () => { try { const res = await fetch('/api/data', { signal: controller.signal }); const data = await res.json(); setData(data); } catch (err) { if (err.name !== 'AbortError') { console.error('Fetch failed:', err); } } }; fetchData(); return () => controller.abort(); }, []); ✅ Best Practices for Fetching Data 1. Use useEffect for async side effects Use useEffect for async side effects import { useState, useEffect } from 'react'; function UserProfile({ userId }) { const [profile, setProfile] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); useEffect(() => { const controller = new AbortController(); const fetchProfile = async () => { try { setLoading(true); const res = await fetch(`/api/users/${userId}`, { signal: controller.signal, }); if (!res.ok) throw new Error('Failed to fetch'); const data = await res.json(); setProfile(data); } catch (err) { if (err.name !== 'AbortError') { setError(err.message); } } finally { setLoading(false); } }; fetchProfile(); return () => controller.abort(); }, [userId]); if (loading) return <p>Loading...</p>; if (error) return <p>Error: {error}</p>; if (!profile) return null; return ( <div> <h2>{profile.name}</h2> <p>{profile.email}</p> </div> ); } import { useState, useEffect } from 'react'; function UserProfile({ userId }) { const [profile, setProfile] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); useEffect(() => { const controller = new AbortController(); const fetchProfile = async () => { try { setLoading(true); const res = await fetch(`/api/users/${userId}`, { signal: controller.signal, }); if (!res.ok) throw new Error('Failed to fetch'); const data = await res.json(); setProfile(data); } catch (err) { if (err.name !== 'AbortError') { setError(err.message); } } finally { setLoading(false); } }; fetchProfile(); return () => controller.abort(); }, [userId]); if (loading) return <p>Loading...</p>; if (error) return <p>Error: {error}</p>; if (!profile) return null; return ( <div> <h2>{profile.name}</h2> <p>{profile.email}</p> </div> ); } 🔁 When Should You Fetch? On component mount: use useEffect(() => {...}, []) On prop or state change: add that prop/state to the dependency array Don’t fetch inside render or conditionally call hooks On component mount: use useEffect(() => {...}, []) useEffect(() => {...}, []) On prop or state change: add that prop/state to the dependency array Don’t fetch inside render or conditionally call hooks Summary Never mark the main useEffect function as async Clean up async tasks on unmount using AbortController or flags Show loading and error states for better UX Keep logic simple and readable Never mark the main useEffect function as async useEffect async Clean up async tasks on unmount using AbortController or flags AbortController Show loading and error states for better UX Keep logic simple and readable Async logic can easily go wrong in React if you're not careful. But with clear structure and small best practices, it becomes reliable and easy to manage. Real-World Examples: API Request with fetch(), Loading States, and Cleaning Up fetch() Most tutorials stop at just showing how to fetch data. But in real projects, you also need to: Show a loading spinner while the request is running Handle errors if something goes wrong Cancel the request if the component unmounts Show a loading spinner while the request is running Handle errors if something goes wrong Cancel the request if the component unmounts Let’s walk through a clean and practical React example. 1. The Setup We’re going to build a simple component that: Loads user data from an API Shows a loading message Handles errors Cancels the request if the component unmounts Loads user data from an API Shows a loading message Handles errors Cancels the request if the component unmounts 2. The Code import { useEffect, useState } from "react"; function UserData() { const [user, setUser] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); useEffect(() => { const controller = new AbortController(); const signal = controller.signal; async function fetchUser() { try { setLoading(true); const res = await fetch("https://jsonplaceholder.typicode.com/users/1", { signal }); if (!res.ok) { throw new Error("Failed to fetch user data"); } const data = await res.json(); setUser(data); setError(null); } catch (err) { if (err.name !== "AbortError") { setError(err.message); setUser(null); } } finally { setLoading(false); } } fetchUser(); // Cleanup: cancel the request if the component unmounts return () => controller.abort(); }, []); if (loading) return <p>Loading...</p>; if (error) return <p>Error: {error}</p>; return ( <div> <h2>{user.name}</h2> <p>Email: {user.email}</p> </div> ); } export default UserData; import { useEffect, useState } from "react"; function UserData() { const [user, setUser] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); useEffect(() => { const controller = new AbortController(); const signal = controller.signal; async function fetchUser() { try { setLoading(true); const res = await fetch("https://jsonplaceholder.typicode.com/users/1", { signal }); if (!res.ok) { throw new Error("Failed to fetch user data"); } const data = await res.json(); setUser(data); setError(null); } catch (err) { if (err.name !== "AbortError") { setError(err.message); setUser(null); } } finally { setLoading(false); } } fetchUser(); // Cleanup: cancel the request if the component unmounts return () => controller.abort(); }, []); if (loading) return <p>Loading...</p>; if (error) return <p>Error: {error}</p>; return ( <div> <h2>{user.name}</h2> <p>Email: {user.email}</p> </div> ); } export default UserData; 3. Key Takeaways → AbortController helps us cancel the request if the component goes away before it finishes. AbortController → We use simple states: loading, error, and user to track what’s happening. loading error user → The try-catch-finally block ensures we always stop loading and handle errors properly. try-catch-finally 4. Why This Matters In real apps: Users navigate fast Components mount and unmount often You need to avoid memory leaks or "can't update unmounted component" errors Users navigate fast Components mount and unmount often You need to avoid memory leaks or "can't update unmounted component" errors This small change—cleaning up your fetch() request—saves you from a lot of silent bugs. fetch() Key Takeaways: What to Remember and Apply Today It’s easy to get lost in code examples. So let’s strip it all down. Here’s what actually matters when you’re working with API requests in real-world React apps: 1. Always Handle Loading and Error States Don't just fetch data and render it blindly. ✅ Show a loading indicator✅ Show an error message if the fetch fails✅ Only show data when it’s ready if (loading) return <p>Loading...</p>; if (error) return <p>Error: {error}</p>; if (loading) return <p>Loading...</p>; if (error) return <p>Error: {error}</p>; This alone improves user experience more than adding animations or fancy CSS. 2. Use AbortController to Avoid Side Effects AbortController When your component unmounts before the fetch finishes, it can cause problems. ✅ Cancel the request when the component unmounts const controller = new AbortController(); fetch(url, { signal: controller.signal }); // Cleanup return () => controller.abort(); const controller = new AbortController(); fetch(url, { signal: controller.signal }); // Cleanup return () => controller.abort(); This protects your app from memory leaks and annoying warning messages in the console. 3. Wrap API Calls in try-catch-finally try-catch-finally Errors happen. APIs fail. Networks break. ✅ Wrap your logic in try-catch-finally to handle every outcome: try-catch-finally try { const res = await fetch(url); if (!res.ok) throw new Error("Something went wrong"); const data = await res.json(); } catch (err) { // Show error } finally { // Stop loading } try { const res = await fetch(url); if (!res.ok) throw new Error("Something went wrong"); const data = await res.json(); } catch (err) { // Show error } finally { // Stop loading } This pattern works. It’s reliable. And it makes debugging much easier. 4. Keep It Simple You don’t need state machines, reducers, or 3rd party libraries to do basic data fetching. Start with: useEffect for side effects useState for loading, data, and error fetch() for simple requests useEffect for side effects useEffect useState for loading, data, and error useState fetch() for simple requests fetch() Then scale complexity only when the project needs it. only 5. Ship It First, Then Improve It’s better to write a simple working fetch than to spend hours perfecting architecture for a feature that might change. Start with this: useEffect(() => { fetchData(); }, []); useEffect(() => { fetchData(); }, []); Add this when needed: Cleanup with AbortController Loading and error states Better UX and retries Cleanup with AbortController AbortController Loading and error states Better UX and retries TL;DR You don’t need to be fancy. You just need to be reliable. Today, focus on: → Showing users what’s happening (loading / error)→ Cleaning up requests to avoid bugs→ Writing fetch logic that actually works Simple wins. Every. Single. Time. Final Thoughts: Writing Clean Async Logic in React Doesn’t Have to Be Hard Here’s the truth: Most bugs in frontend apps don’t come from bad logic. They come from missing the basics: → Forgetting to handle loading→ Ignoring error states→ Not cleaning up async side effects Async logic in React feels tricky only when you try to do too much too early. Let’s break down what actually works. Clean Async Logic in 5 Steps You don’t need a complex state manager.You don’t need external libraries. You just need a plan. Here’s a simple structure you can copy for every API call: import { useState, useEffect } from "react"; function MyComponent() { const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); useEffect(() => { const controller = new AbortController(); const signal = controller.signal; async function fetchData() { try { setLoading(true); const res = await fetch("https://api.example.com/data", { signal }); if (!res.ok) { throw new Error("Failed to fetch"); } const result = await res.json(); setData(result); setError(null); } catch (err) { if (err.name !== "AbortError") { setError(err.message); } setData(null); } finally { setLoading(false); } } fetchData(); return () => controller.abort(); }, []); if (loading) return <p>Loading...</p>; if (error) return <p>Error: {error}</p>; return <div>{JSON.stringify(data)}</div>; } import { useState, useEffect } from "react"; function MyComponent() { const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); useEffect(() => { const controller = new AbortController(); const signal = controller.signal; async function fetchData() { try { setLoading(true); const res = await fetch("https://api.example.com/data", { signal }); if (!res.ok) { throw new Error("Failed to fetch"); } const result = await res.json(); setData(result); setError(null); } catch (err) { if (err.name !== "AbortError") { setError(err.message); } setData(null); } finally { setLoading(false); } } fetchData(); return () => controller.abort(); }, []); if (loading) return <p>Loading...</p>; if (error) return <p>Error: {error}</p>; return <div>{JSON.stringify(data)}</div>; } What Makes This "Clean"? ✅ Clear responsibilities (data, loading, error) ✅ Handles failure without crashing ✅ Cancels fetch if the user navigates away ✅ Doesn't rely on extra tools ✅ Clear responsibilities (data, loading, error) data loading error ✅ Handles failure without crashing ✅ Cancels fetch if the user navigates away ✅ Doesn't rely on extra tools This is enough for most real-world use cases. You Don’t Need Fancy Tools to Write Good Code Start with plain fetch(). fetch() Understand the problem. Solve it cleanly. Then, if the logic grows complex, move that code into a custom hook or use a library like react-query. if react-query But don’t reach for tools because others do.Reach for them becauseyour code needs it. your code needs it Final Word Writing clean async code in React is not about being clever. It’s about being clear. clear → Show users what’s going on→ Catch errors early→ Clean up when the component unmounts That’s it. You do that, your UI will be reliable—even under pressure. And that’s what real-world code needs to be.