If you’re making fetch calls in a useEffect in your React app, then you’re doing it wrong. We hear that every day. But what should we do instead?
Well, as it turns out, the React team has heard us loud and clear. React 19.2 doesn’t just patch over async problems—it rebuilds async handling from the ground up with use(), <Suspense>, useTransition(), and now View Transitions.
Together, these primitives turn async logic from a necessary evil into a first-class architectural feature. Let me walk you through how React’s async story has completely changed, and why it matters for your team.
The Replay is a weekly newsletter for dev and engineering leaders.
Delivered once a week, it's your curated guide to the most important conversations around frontend dev, emerging AI tools, and the state of modern software.
useEffect + isLoadingLet’s start with an example that shows the old useEffect/fetch combo chestnut:
function ImageViewer() {
const [imageId, setImageId] = useState(1);
const [imageData, setImageData] = useState(null);
const [isPending, setIsPending] = useState(false);
useEffect(() => {
setIsPending(true);
fetchImage(imageId).then((data) => {
setImageData(data);
setIsPending(false);
});
}, [imageId]);
return (
<div>
<button onClick={() => setImageId((id) => id + 1)}>
Next Image
</button>
{isPending ? <span>Loading...</span> : <img src={imageData} />}
</div>
);
}
fetchImage in this case is just a wrapper around fetch to keep the example short.
Now, this pattern works, but you’re doing a lot of repetitive makework. For example, you’re managing three pieces of state (imageId, imageData, isPending) when really you just want to show an image. The loading state logic is manual, and the error handling is often an afterthought. And chances are, every async operation looks slightly different across your codebase.
Worst of all, that useEffect dependency array is a landmine. Miss a dependency and you get stale closures. Include too many dependencies, and you trigger infinite loops. It’s the source of countless production bugs.
It’s not great, and let’s be honest, the best you’re going to do with this kind of code is not screw it up. And that’s not the kind of code you want to be writing.
React 19.2 gives us a completely different approach. Instead of managing promises with useEffect, we work with promises directly using use() and <Suspense>.
use()+: The core patternHere’s that same component rewritten with React’s new use Hook and Suspense component combo:
import { use, Suspense, useState } from "react";
function ImageViewer() {
const [imageId, setImageId] = useState(1);
const [imageDataPromise, setImageDataPromise] = useState(() => fetchImage(1));
const handleNext = () => {
const nextId = imageId + 1;
setImageId(nextId);
setImageDataPromise(fetchImage(nextId));
};
return (
<div>
<button onClick={handleNext}>Next Image</button>
<Suspense fallback={<ImageSkeleton />}>
<Image imageDataPromise={imageDataPromise} />
</Suspense>
</div>
);
}
function Image({ imageDataPromise }: { imageDataPromise: Promise<ImageData> }) {
const image = use(imageDataPromise);
return (
<div>
<h2>{image.title}</h2>
<img src={image} alt={image.title} />
</div>
);
}
Look at how much cleaner this is. No useEffect. No manual isPending state. No conditional rendering logic in the parent. We’re storing a promise instead of data, and React handles the rest.
But how does this actually work?
<Suspense>When <Suspense> tries to render its children, the <Image> component calls use(imageDataPromise). If that promise isn’t resolved yet, use() literally throws the promise. Yes – throws it – like an exception.
Now, I know what you’re thinking: “Using exceptions for control flow? That’s weird!” But hear me out, because it’s honestly brilliant.
That thrown promise bubbles up through the component tree until <Suspense> catches it. <Suspense> then knows: “Ah, my child needs data from this promise. I’ll show my fallback and wait for this promise to resolve.” When the promise resolves, <Suspense> re-renders its children, use() returns the data, and the image appears.
This eliminates the need for conditional rendering at every level of your component tree. And the loading state becomes declarative, because you can wrap async components in <Suspense> and define what to show while loading. React handles the timing.
useTransition() + action: Smoother interactionsBut we can make this even better. Right now, when you click “Next Image”, the button stays clickable while the new image loads. That’s not great UX; the user might click multiple times, creating a race condition:
React 19.2 introduces action props and useTransition() to handle this elegantly:
function Button({
action,
children,
}: {
action: () => Promise<void>;
children: React.ReactNode;
}) {
const [isPending, startTransition] = useTransition();
const onClick = () => {
startTransition(async () => {
await action();
});
};
return (
<button onClick={onClick} disabled={isPending}>
{children}
</button>
);
}
function ImageViewer() {
const [imageId, setImageId] = useState(1);
const [imageDataPromise, setImageDataPromise] = useState(() => fetchImage(1));
const handleNext = async () => {
const nextId = imageId + 1;
setImageId(nextId);
setImageDataPromise(fetchImage(nextId));
};
return (
<div>
<Button action={handleNext}>
Next Image
</Button>
<Suspense fallback={<ImageSkeleton />}>
<Image imageDataPromise={imageDataPromise} />
</Suspense>
</div>
);
}
Now, there is nothing inherently special about a prop named with action. It’s just a convention that tells other developers that this button will wrap the handler in a transition.
Now that we are into the UI part of async, let’s talk about View Transitions. React 19.2 (on the experimental branch) adds support for the browser’s native View Transitions API. This gives you butter-smooth, GPU-accelerated animations when async content loads. And it takes just a few lines of code.
First, add some CSS animations:
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes fadeOut {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
@keyframes pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
::view-transition-old(image-container) {
animation: fadeOut 0.3s ease-out;
}
::view-transition-new(image-container) {
animation: fadeIn 0.3s ease-in;
}
::view-transition-old(button-pulse) {
animation: pulse 0.3s ease-in-out;
}
Then add View Transitions to your components:
import { experimental_useViewTransition as ViewTransition } from "react";
function Image({ imageDataPromise }: { imageDataPromise: Promise<string> }) {
const image = use(imageDataPromise);
return <img src={image.url} className="image-container" />;
}
function ImageSkeleton() {
return (
<div className="image-skeleton" />
);
}
function Button({
action,
children,
disabled,
}: {
action: () => Promise<void>;
children: React.ReactNode;
disabled: boolean;
}) {
const [isPending, startTransition] = useTransition();
const onClick = () => {
startTransition(async () => {
await action();
});
};
return (
<ViewTransition name="button-pulse">
<button onClick={onClick} disabled={isPending}>
{children}
</button>
</ViewTransition>
);
}
function ImageViewer() {
const [imageId, setImageId] = useState(1);
const [imageDataPromise, setImageDataPromise] = useState(() => fetchImage(1));
const handleNext = async () => {
const nextId = imageId + 1;
setImageId(nextId);
setImageDataPromise(fetchImage(nextId));
};
return (
<div>
<Button action={handleNext}>Next Image</Button>
<ViewTransition name="image-container">
<Suspense fallback={<ImageSkeleton />}>
<Image imageDataPromise={imageDataPromise} />
</Suspense>
</ViewTransition>
</div>
);
}
That’s it. The browser handles the GPU-accelerated transitions automatically. Your skeleton fades out, your image fades in, and your button pulses while loading. It’s silky smooth, and you barely wrote any code:
Note: You’ll need React 19.2 experimental for this: npm install react@experimental react-dom@experimental
These changes aren’t just about cleaning up the useEffect/fetch mess. It’s about building a much better React that finally acknowledges async as a first-class concern.
These patterns mean that the code your team writes is easier to reason about and maintain. The framework is handling more. The UI is snappier because it’s updating on demand. And you are leveraging more of the underlying framework to give your application a modern feel with smooth animations.
This isn’t your granddad’s React, and it’s time for teams to step up to using these new tools.
React 19.2 brings “async everywhere.” These primitives work together as a system:
use() extracts data from promises<Suspense> declaratively handles loading statesuseTransition() gives you loading flags for freeThe old way, useEffect and fetch, were a serious headache for React, but we have great alternatives. React’s async primitives are less code, fewer bugs, and better UX.
Your team can focus more on the experience you’re building and less on the plumbing that powers it.
That said, if you’re not ready to jump to React 19.2, there’s an excellent alternative: TanStack Query (formerly React Query).
Here’s that same component with TanStack Query:
TanStack Query works on any React version and gives you automatic caching, background refetching, optimistic updates, and more. It’s a battle-tested solution that solves the same problems as React 19.2’s async primitives, just with a different API. There is a good reason why so many React apps have react-query installed.
React 19.2 is out. It’s stable. And you should be using it for production use (minus View Transitions, which are still being refined). The async shift is here. React has finally solved its biggest pain point, and the framework is better for it.
If you haven’t explored React 19.2 yet, now’s the time. Try out use() and <Suspense> on a side project and see how much simpler async logic becomes. You’ll wonder how you ever lived without it.
Install LogRocket via npm or script tag. LogRocket.init() must be called client-side, not
server-side
$ npm i --save logrocket
// Code:
import LogRocket from 'logrocket';
LogRocket.init('app/id');
// Add to your HTML:
<script src="https://cdn.lr-ingest.com/LogRocket.min.js"></script>
<script>window.LogRocket && window.LogRocket.init('app/id');</script>

The web has always had an uneasy relationship with connectivity. Most applications are designed as if the network will be […]

Streaming AI responses is one of the easiest ways to improve UX. Here’s how to implement it in a Next.js app using the Vercel AI SDK—typing effect, reasoning, and all.

Learn how React Router’s Middleware API fixes leaky redirects and redundant data fetching in protected routes.

A developer’s retrospective on creating an AI video transcription agent with Mastra, an open-source TypeScript framework for building AI agents.
Would you be interested in joining LogRocket's developer community?
Join LogRocket’s Content Advisory Board. You’ll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.
Sign up now