React 19.2: The async shift is finally here

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 old way: useEffect + isLoading

Let’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.

The new async primitives

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 pattern

Here’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?

The magic behind <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 interactions

But 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:gif of use transition with clickable button

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.

View Transitions: GPU-accelerated polish

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:gif of view transitions now with non clickable button

Note: You’ll need React 19.2 experimental for this: npm install react@experimental react-dom@experimental

Why this matters for frontend teams

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.

Key takeaways

React 19.2 brings “async everywhere.” These primitives work together as a system:

  • use() extracts data from promises
  • <Suspense> declaratively handles loading states
  • useTransition() gives you loading flags for free
  • View Transitions add GPU-accelerated polish

The 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.

The non-19.2 option: TanStack Query

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.

What’s next: The future of frontend with React 19.2

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.

The post React 19.2: The async shift is finally here appeared first on LogRocket Blog.

 

This post first appeared on Read More