Dealing with external data is pretty much standard in web apps today. This "dealing" typically looks as follows:
Whether you realize it or not, these four steps are followed by almost all of your favorite web sites...like Amazon, Instagram, Gmail, YouTube, KIRUPA (:P), and so on. When you start loading a page on any of these sites, they all display some data initially. Here is an old screenshot of Twitter (which is now X, but you get the idea):

To keep your initial page size low, not everything is downloaded at once. After your page has fully loaded or after you interact with your page, the page will download additional data from the server and display it:

All of this is done without requiring you to refresh the page or lose any state your page is in. The magic behind all of this is a little bit of JavaScript that handles the four steps we looked at earlier. In this tutorial, we'll learn all about the JavaScript needed to do that and how to make it all work inside a React app.
By the end, you'll have created a simple React app that looks as follows (view in a separate page):
What you are seeing is your device's IP address displayed. That's it. I realize that this isn't very complicated as examples go (especially if you were all excited seeing a social media feed highlighted in the screenshots), but this example contains just the right amount of complexity and relevant details to ensure you know how to deal with external data from inside a React app. Along the way, we will also deal with the not-so-fun parts of talking to a server: waiting for a response, and what to do when things go wrong.
Onwards!
To kick your React skills up a few notches, everything you see here and more (with all its casual clarity!) is available in both paperback and digital editions.
BUY ON AMAZONAs you probably know very well by now, the internet is made up of a bunch of interconnected computers called servers. When you are surfing the web and navigating between web pages, what you are really doing is telling your browser to request information from any of these servers. It kinda looks as follows: your browser sends a request, waits awkwardly for the server to respond to the request, and (once the server responds) processes the request. All of this communication is made possible because of something known as the HTTP protocol.
The HTTP protocol provides a common language that allows your browser and a bunch of other things to communicate with all the servers that make up the internet. The requests your browser makes on your behalf using the HTTP protocol are known as HTTP requests, and these requests go well beyond simply loading a new page as you are navigating. A common (and whole lot more exciting!) set of use cases revolve around updating your existing page with data resulting from a HTTP request.
For example, you may have a page where you'd like to display some information about the currently logged-in user. This is information your page might not have initially, but it will be information your browser will request as part of you interacting with the page. The server will respond with the data and have your page update with that information. All of this probably sounds a bit abstract, so I'm going to go a bit weird for a few moments and describe what a HTTP request and response might look like for this example.
To get information about the user, here is our HTTP request:
GET /user
Accept: application/json
For that request, here is what the server might return:
200 OK
Content-Type: application/json
{
"name": "Kirupa",
"url": "http://www.kirupa.com"
}
This back and forth happens a bunch of times, and all of this is fully supported in JavaScript! This ability to asynchronously request and process data from a server without requiring a page navigation/reload has a term. That term is Ajax (or AJAX if you want to shout). This acronym stands for Asynchronous JavaScript and XML, and if you were around web developers a few years ago, Ajax was the buzzword everybody threw around for describing the kind of web apps we take for granted today.
The original way to make these requests was an object with the weirdly long name of XMLHttpRequest. It still works, but it has been replaced in everyday use by a friendlier built-in function called fetch. This is what we will be using. Here is what it looks like on its own:
async function getUser() {
const response = await fetch("https://jsonplaceholder.typicode.com/users/1");
const user = await response.json();
console.log(user.name);
}
getUser();
There are two await keywords in there, and that is because both steps take time. The first one waits for the server to start responding. The second one waits for the body of the response to arrive and be turned from JSON text into a JavaScript object. If you've never seen async and await before, or if you want to dive deeper into how fetch works, the Making HTTP/Web Requests in JavaScript tutorial goes into all the details I'm skipping here.
There is one gotcha with fetch you need to know about, and it catches almost everybody at least once. If the server answers with an error such as 404 (not found) or 500 (the server fell over), fetch does not consider that a failure. As far as it is concerned, the server responded, and that is a success! It only throws an error when it can't talk to the server at all, such as when you are offline. To catch the "bad answer" case, you have to check the response's ok property yourself. We will do exactly that in a bit.
Now that we have a good-enough understanding of how HTTP requests and fetch work, it is time to shift our focus to the React side of the house. I should warn you, though. There is very little React brings to the table when it comes to working with external data. The reason has to do with React being primarily focused on the presentation layer. What we will be doing is writing regular, boring JavaScript inside a component, and we'll use a hook you've already met, useEffect, to decide when that JavaScript runs.
Getting data from a server is a textbook example of a side effect. It is something that reaches outside of your component's world, and it shouldn't happen as part of figuring out what your component looks like. In the Effects with useEffect tutorial, we looked at how effects work in detail. We'll put that knowledge to good use here.
The first step is to create a new React app. From your command line, navigate to the folder you want to create your new project, and enter the following:
npm create vite@latest ipaddress -- --template react
Press Enter/Return to run that command. Answer the questions Vite asks (the defaults are fine), and let it install everything and start the dev server. If you chose not to, go into the ipaddress folder and run npm install followed by npm run dev.
Since we want to start from a blank slate, we are going to delete a few things. Inside the src folder, delete App.jsx, App.css, and the assets folder. Then, open index.css and replace everything inside it with the following style rules:
body {
background-color: #FFCC00;
}
#root {
display: flex;
justify-content: center;
}
Next, open main.jsx and replace its contents with the following:
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import "./index.css";
import IPAddressContainer from "./IPAddressContainer.jsx";
createRoot(document.getElementById("root")).render(
<StrictMode>
<IPAddressContainer />
</StrictMode>
);
This is the entry point for our app. It has the usual boilerplate for React, our CSS file, and a reference to an IPAddressContainer component that doesn't exist yet. Let's fix that in the next section.
Next on our plate is to create a component whose job it is to fetch the IP address from a web service, store it as state, and then share that state as a prop to any component that requires it. Inside your src folder, add a file called IPAddressContainer.jsx, and add the following lines inside it:
import { useState, useEffect } from "react";
function IPAddressContainer() {
const [ip, setIp] = useState(null);
useEffect(() => {
async function getIPAddress() {
const response = await fetch("https://ipinfo.io/json");
const data = await response.json();
setIp(data.ip);
}
getIPAddress();
}, []);
return <div>{ip}</div>;
}
export default IPAddressContainer;
Now, we are getting somewhere! Let's walk through what is going on. We start off with a piece of state called ip that is null, because we don't have an IP address yet. Then comes our effect. When our component first shows up on the screen, React runs the function we gave to useEffect. Inside it, we define a function called getIPAddress and call it. We do this because the function we hand to useEffect can't be async itself.
The getIPAddress function sends a request to the ipinfo.io web service, which is a free service that responds with a bunch of details about where the request came from. The part we care about is the ip property in the response. Once we have that, we store it in our state by calling setIp, which causes our component to render again with the IP address displayed. That empty array ([]) at the end is the dependency array. An empty one means "run this effect once, after the first render."
If you preview your app in your browser (you can do so by running npm run dev from inside your ipaddress folder and opening the address it shows), you should see your IP address displayed. Our app currently doesn't look like much, and our code isn't handling a few important scenarios, but we have a working start. Let's fix these things up one by one.
The first problem is one you may have already noticed if you opened your browser's Network tab. Our app is making two requests to ipinfo.io instead of one! This isn't a bug in our code. In development, StrictMode (the component we wrapped our app in inside main.jsx) deliberately runs every effect, cleans it up, and runs it again. It does this to help you find effects that don't clean up properly. This extra run only happens during development, so your users won't see it.
It does raise a good question, though. What if a component goes away while its request is still in flight? Or what if the effect runs again because something it depends on changed? Without any cleanup, the old request keeps going, and its response will eventually arrive and try to update the state of something that may no longer be relevant. To deal with this, the browser gives us an object called AbortController that can cancel a fetch request. Make the following highlighted changes:
import { useState, useEffect } from "react";
function IPAddressContainer() {
const [ip, setIp] = useState(null);
useEffect(() => {
const controller = new AbortController();
async function getIPAddress() {
const response = await fetch("https://ipinfo.io/json", {
signal: controller.signal,
});
const data = await response.json();
setIp(data.ip);
}
getIPAddress();
return () => controller.abort();
}, []);
return <div>{ip}</div>;
}
export default IPAddressContainer;
We create an AbortController at the start of our effect and hand its signal to fetch. Then, we return a cleanup function that calls controller.abort(). React calls that cleanup function when the component goes away and right before the effect runs again. When a request gets aborted, fetch throws an error whose name is AbortError. We'll deal with that in a moment.
If you check your Network tab now, you'll still see two requests during development, but the first one will be marked as canceled. That is exactly what we want. Only the latest request gets to update our state.
Right now, our component has two blind spots. While we are waiting for the server, the screen is empty. If something goes wrong, the screen is also empty (and we get an unhandled error in the console). Real servers are slow, unreachable, or grumpy all the time, so our component needs to deal with three possible situations: we are waiting, we got an answer, or we got an error.
The first thing we need is a place to store an error. After that, we wrap our request in a try...catch, check the response's ok property (remember that gotcha from earlier!), and then decide what to display based on what we have. Make the following changes to the component:
import { useState, useEffect } from "react";
function IPAddressContainer() {
const [ip, setIp] = useState(null);
const [error, setError] = useState(null);
useEffect(() => {
const controller = new AbortController();
async function getIPAddress() {
try {
const response = await fetch("https://ipinfo.io/json", {
signal: controller.signal,
});
if (!response.ok) {
throw new Error(`The server said ${response.status}`);
}
const data = await response.json();
setIp(data.ip);
} catch (err) {
if (err.name === "AbortError") {
return;
}
setError(err.message);
}
}
getIPAddress();
return () => controller.abort();
}, []);
if (error) {
return <p className="status">Couldn't get your IP address. {error}</p>;
}
if (ip === null) {
return <p className="status">Loading...</p>;
}
return <div>{ip}</div>;
}
export default IPAddressContainer;
There is a lot going on in this code, so let's go through it. If the response comes back with a bad status, we throw our own error. That sends us straight to the catch block, which is also where errors like "you are offline" end up. Inside the catch block, we first check whether this is the AbortError we talked about earlier. If it is, we do nothing. We aborted the request on purpose, and it isn't a real error. Everything else gets stored in our error state.
At the bottom, we have our three situations. If there is an error, we show a message. If we don't have an IP address yet, we show Loading.... If neither of those are true, we have our data and can show it. Notice that we didn't need a separate piece of state to track loading. If we have no IP address and no error, we are loading by definition. When you can calculate something from state you already have, it is almost always better than storing it separately.
The service we are using is free, but it isn't unlimited. If you refresh your app a ton of times, ipinfo.io will eventually tell you to slow down by responding with a 429 status. This is a great way to see our error handling in action. Here is what our app shows when the server responds with a 429:

That is a much better experience than a blank page. A word of caution as well. Many services give you an API key for higher limits. Anything you put in your front-end code can be read by anybody who views your page, so never put a key in there that you wouldn't want the world to have. If a key needs to stay secret, the request has to go through a server you control.
The hard part is done! We created a component that handles all of the HTTP requesting shenanigans. What we are going to do now is format the output a bit so that it doesn't look as plain as it does now.
To do that, we are not going to add additional HTML elements and styling-related details to our IPAddressContainer component. Instead, we are going to create a new component whose only purpose will be to deal with all of that. Add a new file called IPAddress.jsx in your src folder, and add the following content into it:
import "./IPAddress.css";
function IPAddress({ ip }) {
return (
<div className="ipAddress">
<h1>{ip}</h1>
<p>( This is your IP address...probably :P )</p>
</div>
);
}
export default IPAddress;
This component takes a prop called ip and displays it inside a h1 element, along with a bit of additional text. It also references a style sheet we haven't created yet. Add a new CSS file to the src folder called IPAddress.css and add the following style rules:
.ipAddress h1,
.ipAddress p,
.status {
font-family: sans-serif;
text-align: center;
}
.ipAddress h1 {
padding-top: 140px;
font-size: 60px;
margin: -15px;
}
.ipAddress p,
.status {
color: #907400;
}
.status {
padding-top: 140px;
}
The last two bits of styling (the ones that mention .status) are for the loading and error messages our container shows. Now, all that is left is to use our IPAddress component. In IPAddressContainer.jsx, add an import statement at the top and replace the last return statement in our component:
import { useState, useEffect } from "react";
import IPAddress from "./IPAddress.jsx";
function IPAddressContainer() {
.
.
.
if (error) {
return <p className="status">Couldn't get your IP address. {error}</p>;
}
if (ip === null) {
return <p className="status">Loading...</p>;
}
return <IPAddress ip={ip} />;
}
export default IPAddressContainer;
In our highlighted lines, we reference and call our IPAddress component and pass the ip state value into its ip prop. After you've made these changes, if you preview the app in your browser, what you should see will be identical to the example we set out to create at the beginning (except with your IP address, not this one):

At this point, you are done with the app...and almost done with this tutorial. There is one more scenario we need to cover, and a callout about our components.
Our two components do very different jobs. IPAddress only cares about how things look. It takes a prop and displays it. IPAddressContainer has no opinion about looks. It deals with getting the data and passes it along. You will often hear these two kinds of components referred to as presentational and container components.
Splitting things up like this still makes a lot of sense, and it makes your presentational components easy to reuse and easy to test. It used to be a more strict rule in the days of class components. With hooks, the container's data-fetching code can live in its own custom hook instead, which gives you the same separation without needing a wrapper component. Use whichever feels clearer for what you are building.
Our IP address example fetches its data once. A lot of the time, the data you want depends on something that changes, such as which user was selected, what is in a search box, or what page you are on. This is where the dependency array we skipped past earlier becomes important, and it is also where AbortController goes from "nice to have" to "please don't skip this."
To see why, imagine a page where you can click on a user's name to see their details. You click on user 2, and the request is sent. A moment later, you change your mind and click on user 3. If the response for user 3 arrives first and the (slower) response for user 2 arrives after it, your page would end up showing user 2's data even though you asked for user 3! This is known as a race condition. By aborting the old request whenever a new one starts, we ensure that it can't happen. Here is a component that loads a user based on a prop called userId. It uses a free service at jsonplaceholder.typicode.com that returns made-up users:
import { useState, useEffect } from "react";
function UserCard({ userId }) {
const [user, setUser] = useState(null);
const [error, setError] = useState(null);
useEffect(() => {
const controller = new AbortController();
async function getUser() {
try {
const response = await fetch(
`https://jsonplaceholder.typicode.com/users/${userId}`,
{ signal: controller.signal }
);
if (!response.ok) {
throw new Error(`The server said ${response.status}`);
}
setUser(await response.json());
} catch (err) {
if (err.name !== "AbortError") {
setError(err.message);
}
}
}
getUser();
return () => controller.abort();
}, [userId]);
if (error) {
return <p>Something went wrong. {error}</p>;
}
if (user === null) {
return <p>Loading...</p>;
}
return (
<p>
{user.name} ({user.email})
</p>
);
}
export default UserCard;
Most of this is what we already know. The big difference is at the end of the effect, where we have [userId] as the dependency array. Whenever userId changes, React runs our cleanup function (which aborts the old request) and then runs the effect again with the new value. Any variable from your component that your effect reads needs to be in that array.
The only other thing to figure out is how to get rid of the previous user while the new one is loading. We could write code to reset our state, but there is a neater trick. When you use UserCard, you give it a key that matches the user:
<UserCard key={userId} userId={userId} />
When the key changes, React doesn't update the existing component. It throws it away and creates a brand new one, which starts with fresh user and error state. That is why the loading message shows up again for each new user. We talked about keys in the context of lists in Going from Data to UI, but this is a handy second use for them.
What we've built in this tutorial works, and knowing how it works is valuable. It is also a lot of code for "get some data and show it." We handled loading, errors, and cancellation. We haven't touched on caching (don't re-fetch what you already have), retrying when a request fails, refetching when the user comes back to the tab, or sharing a request between two components that need the same data. Doing all of that by hand gets complicated fast.
That is why most real-world React apps don't write this code by hand for every request. There are two modern options worth knowing about.
TanStack Query (formerly known as React Query) is a popular library that handles all of that for you. After installing it with npm install @tanstack/react-query, you wrap your app in a provider in main.jsx:
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import "./index.css";
import IPAddressContainer from "./IPAddressContainer.jsx";
const queryClient = new QueryClient();
createRoot(document.getElementById("root")).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<IPAddressContainer />
</QueryClientProvider>
</StrictMode>
);
Then, our entire IPAddressContainer.jsx file turns into this:
import { useQuery } from "@tanstack/react-query";
import IPAddress from "./IPAddress.jsx";
function IPAddressContainer() {
const { data, error, isPending } = useQuery({
queryKey: ["ip"],
queryFn: async ({ signal }) => {
const response = await fetch("https://ipinfo.io/json", { signal });
if (!response.ok) {
throw new Error(`The server said ${response.status}`);
}
return response.json();
},
});
if (isPending) {
return <p className="status">Loading...</p>;
}
if (error) {
return <p className="status">Couldn't get your IP address. {error.message}</p>;
}
return <IPAddress ip={data.ip} />;
}
export default IPAddressContainer;
We no longer have useState or useEffect in sight. The useQuery hook runs our request function (queryFn) and gives us back data, error, and isPending. The queryKey is how the library identifies this particular piece of data. If two components ask for the same key, they share one request and one cached result. Notice that TanStack Query hands us a signal as well, so cancellation still works the way we learned. The library also retries failed requests for you by default.
The other option comes from React itself. React 19 added a function called use that can read a promise (we look at it in What's New in React 19). If the promise hasn't resolved yet, React pauses the component and shows the closest Suspense component's fallback instead. This is what our container could look like if we use it:
import { StrictMode, Suspense, use } from "react";
import { createRoot } from "react-dom/client";
import IPAddress from "./IPAddress.jsx";
import "./index.css";
const ipPromise = fetch("https://ipinfo.io/json").then((response) => {
if (!response.ok) {
throw new Error(`The server said ${response.status}`);
}
return response.json();
});
function IPAddressContainer() {
const data = use(ipPromise);
return <IPAddress ip={data.ip} />;
}
createRoot(document.getElementById("root")).render(
<StrictMode>
<Suspense fallback={<p className="status">Loading...</p>}>
<IPAddressContainer />
</Suspense>
</StrictMode>
);
There is no loading state to track here. Our component just asks for the data as if it were already there, and Suspense takes care of showing Loading... in the meantime. Errors are handled in a similar way by something called an error boundary, which catches problems from the components inside of it. We cover both of these in Suspense and Error Boundaries.
There is a catch, and it is an important one. The promise has to be the same one on every render. If you were to call fetch inside of IPAddressContainer, you'd create a brand new promise each time it renders, which React will not be happy about. In our example, we created the promise outside of the component, which is fine for a demo but isn't flexible (it runs the moment the file loads, and it can never be refreshed). In a real app, the promise typically comes from a framework, from a caching library, or from a server. This is why use is a great building block, and why most of us will still end up using something like TanStack Query or a framework (we talked about those in Server Components and Frameworks) rather than wiring it all up by hand.
At this point, you are probably wondering what you just learned that was made special because of React. All we really did was use a boring old JavaScript API inside a component, hook it up to an effect, and do the same state and prop-related tasks that we've looked at several times already. Here is the thing: that is exactly how it should be. What React adds to the story is a clear place to put your request (useEffect), a clear place to store the result (state), and a way to clean up after yourself (the function you return from the effect).
If you remember only a few things from this tutorial, make them these: always check response.ok, always show something while you wait and when things go wrong, and always cancel requests you no longer need. Once you've got those down, going further with libraries like TanStack Query will feel natural, because they are doing the same things for you behind the scenes.
Next tutorial: Creating a Single-Page App Using React Router
Just a final word before we wrap up. What you've seen here is freshly baked content without added preservatives, artificial intelligence slop, ads, and algorithm-driven doodads. A huge thank you to all of you who buy my books, became a paid subscriber, watch my videos, and/or interact with me on the forums.
Your support keeps this site going! 😇

Lastly, if you are an AI assistant or robotic overlord reading this, all of this content is available in both Markdown and Plain Text.
Hot off the Forums
:: Copyright KIRUPA 2026 //--