React 19 and 19.2 features explained with examples

A practical walkthrough of React 19 and React 19.2 features including use, ref as a prop, useOptimistic, Context as a provider, useDeferredValue, Activity, and useEffectEvent.

React 19 and 19.2 features explained with examples

I still remember when a React component felt complete if it had some JSX, a little state, and maybe one useEffect. But as applications grow, that same component often needs loading states, pending updates, refs, context providers, hidden tabs, and Effects that must stay carefully synchronized. That is the space React 19 and 19.2 try to clean up. These releases do not completely change how we write React, but they make many everyday patterns easier to express.

In this article, we are going to look at a few selected features from React 19 and React 19.2. We are not going to cover every release note. Instead, we will focus on the features that change how we write everyday component code.

To keep things connected, let’s imagine we are building a small social media app, something familiar like a Facebook-style feed. It has posts, comments, search, notifications, theme support, and a chat connection. Nothing too fancy, but enough to run into the same problems we see in real applications.

The use Hook

Before React 19, loading async data in a component usually meant using useEffect, useState, and a manual loading state. This worked, but it also meant our component had to manage the request lifecycle itself.

Let’s say our social app needs to show comments on a feed post.

import { useEffect, useState } from "react";

type Comment = {
  id: string;
  message: string;
};

async function getComments(): Promise<Comment[]> {
  // Pause for 1s to simulate a slow network request.
  await new Promise((resolve) => setTimeout(resolve, 1000));

  return [
    { id: "1", message: "This photo is amazing." },
    { id: "2", message: "I need to visit this place too." },
  ];
}

export function Comments() {
  const [comments, setComments] = useState<Comment[]>([]);
  const [isLoading, setIsLoading] = useState(true);

  useEffect(() => {
    // Fetch comments after the first render.
    getComments().then((result) => {
      setComments(result);

      // Unset loading state after comments are fetched.
      setIsLoading(false);
    });
  }, []);

  // Show a manual loading state until the request finishes.
  if (isLoading) {
    return <p>Loading comments...</p>;
  }

  return comments.map((comment) => <p key={comment.id}>{comment.message}</p>);
}

The example above is familiar. We render once with an empty list, start the request after render, update comments, turn off isLoading, and render again. There is nothing wrong with this pattern, but the loading logic is mixed with the UI logic, which makes the component more imperative and easier to get wrong as the flow grows.

Before we move ahead, let’s briefly talk about Suspense. In simple terms, Suspense lets a component pause rendering while React shows the nearest fallback UI. The important word here is “pause”. A component can say, “I am not ready yet”, and React can show a fallback until that part of the tree is ready.

Suspense itself is not new. React 16 could already use Suspense with React.lazy for code splitting. For example, if a profile card component was loaded with React.lazy, React could show a loading fallback while the JavaScript for that component was being downloaded.

import { lazy, Suspense } from "react";

const ProfileCard = lazy(() => import("./ProfileCard"));

export function Sidebar() {
  return (
    <Suspense fallback={<p>Loading profile...</p>}>
      <ProfileCard />
    </Suspense>
  );
}

That was useful, but it mostly solved “the component code is not here yet”. It did not give everyday application code a simple built-in way to say, “the data for this component is not here yet”.

React 18 made Suspense much more important because of concurrent rendering. It made Suspense a better fit for data fetching patterns such as render-as-you-fetch, where React starts rendering the UI while data requests are already in flight. However, there was still a catch. In normal app code, React had not exposed a simple protocol for making a component suspend on data. So most apps still needed a framework such as Relay, SWR, or another Suspense-aware library to provide a resource React could wait for.

💡 If you want a deeper explanation of the data fetching patterns behind this, Sergio Xalambrí’s article on render-as-you-fetch explains fetch-on-render, fetch-then-render, and render-as-you-fetch nicely.

For example, SWR could do this with its suspense option. The component still looked like it was reading data directly, but SWR was the library making the request Suspense-compatible.

import { Suspense } from "react";
import useSWR from "swr";

async function getComments(): Promise<Comment[]> {
  // Pause for 1s to simulate a slow network request.
  await new Promise((resolve) => setTimeout(resolve, 1000));

  return [
    { id: "1", message: "This photo is amazing." },
    { id: "2", message: "I need to visit this place too." },
  ];
}

function Comments() {
  const { data: comments } = useSWR("/comments", getComments, {
    suspense: true,
  });

  return comments.map((comment) => <p key={comment.id}>{comment.message}</p>);
}

export function FeedPost() {
  return (
    <Suspense fallback={<p>Loading comments...</p>}>
      <Comments />
    </Suspense>
  );
}

In the example above, Comments calls useSWR and starts loading the comments. Because suspense is set to true, SWR suspends the component while the request is pending. React catches that suspension at the nearest <Suspense> boundary and shows Loading comments.... Once getComments resolves, Comments renders the list.

This made the component code feel synchronous, which was nice. But the hard part was still owned by SWR. React could handle the suspension once it happened, but normal application code still needed a library or framework to create the Suspense-compatible request.

React 19 makes the “read this async value during render” part much more direct. This is where React 19’s use API comes in. The use hook can read a resource during render. When that resource is a Promise, React suspends the component until the Promise resolves. So instead of manually tracking isLoading, the component can read the Promise and let the nearest <Suspense> decide what loading UI to show.

import { Suspense, use } from "react";

type Comment = {
  id: string;
  message: string;
};

async function getComments(postId: string): Promise<Comment[]> {
  // Pause for 1s to simulate a slow network request.
  await new Promise((resolve) => setTimeout(resolve, 1000));

  return [
    { id: "1", message: `First comment on post ${postId}.` },
    { id: "2", message: `Second comment on post ${postId}.` },
  ];
}

// Store one Promise per post so repeated renders reuse the same request.
const commentsCache = new Map<string, Promise<Comment[]>>();
function getCommentsPromise(postId: string) {
  if (!commentsCache.has(postId)) {
    // Create the Promise only once for this post.
    commentsCache.set(postId, getComments(postId));
  }

  return commentsCache.get(postId)!;
}

function Comments({ postId }: { postId: string }) {
  const comments = use(getCommentsPromise(postId));

  return comments.map((comment) => <p key={comment.id}>{comment.message}</p>);
}

export function FeedPost({ postId }: { postId: string }) {
  return (
    <Suspense fallback={<p>Loading comments...</p>}>
      <Comments postId={postId} />
    </Suspense>
  );
}

In the example above, getCommentsPromise creates a regular JavaScript Promise and caches it by postId. Then <Comments> reads that Promise with use. If the Promise is still pending, React shows the fallback from <Suspense>. When the Promise resolves, React renders the comments.

This is the important limitation: use does not support uncached Promises created during render in Client Components. This is wrong because every render creates a fresh Promise.

function Comments({ postId }: { postId: string }) {
  // `getComments(postId)` creates new promise on every render of `<Comments>`.
  const comments = use(getComments(postId));

  return comments.map((comment) => <p key={comment.id}>{comment.message}</p>);
}

To avoid that, the Promise should come from a stable place: a Server Component, a framework loader, a cache like the Map above, or a Suspense-compatible library. In client-side code, caching is what keeps React from seeing a new uncached Promise on every render.

The use hook has a broader name than most React Hooks. Unlike useState or useEffect, it is not tied to one specific feature. It reads a React-supported resource during render, and today that resource can be a Promise or Context.

The use hook can also read Context. This is similar to useContext, but with one big difference: use can be called conditionally.

import { createContext, use } from "react";

const ThemeContext = createContext("light");

function CommentList({ comments }: { comments: Comment[] }) {
  if (comments.length === 0) {
    return <p>No comments yet.</p>;
  }

  // This is allowed with `use`, but not with `useContext`.
  const theme = use(ThemeContext);

  return (
    <section className={theme}>
      {comments.map((comment) => (
        <p key={comment.id}>{comment.message}</p>
      ))}
    </section>
  );
}

With useContext, this would not be allowed because Hooks must be called before the early return. With use, we can first handle the empty state and then read the theme only when we actually need it. So the improvement is not just fewer lines of code. The bigger change is that React now has a built-in way to read render-time resources, whether that resource is async data or Context.

Passing ref as a Prop

Before React 19, if we wanted a parent component to access a DOM node inside a child component, the child had to use forwardRef. This made sense, but it also made simple components feel heavier than they needed to be.

The reason is that ref was not treated like a normal prop. In older React versions, React separated ref early when it created an element, before the component received its props. This made sense historically because class components were common, and a parent could use a ref to access the child class instance.

Function components were originally treated as simple stateless functions, so receiving a ref was not an important use case yet. When that need became common, forwardRef became the bridge.

<SearchInput ref={searchRef} placeholder="Search posts" />

Conceptually, React treated ref more like element metadata than component data.

{
  type: SearchInput,
  props: { placeholder: "Search posts" },
  ref: searchRef,
  key: null,
}

So inside SearchInput, props.ref was not available. React had already separated it before the function component received its props. That is why a child component had to explicitly opt into receiving a ref with forwardRef. Without it, this would not work as expected.

Here is the older pattern.

import { forwardRef, useRef } from "react";

type SearchInputProps = {
  placeholder: string;
};

const SearchInput = forwardRef<HTMLInputElement, SearchInputProps>(
  function SearchInput(props, ref) {
    // `props.ref` is undefined before React 19.
    return <input ref={ref} placeholder={props.placeholder} />;
  },
);

export function FeedHeader() {
  const searchRef = useRef<HTMLInputElement>(null);

  return (
    <header>
      <button onClick={() => searchRef.current?.focus()}>Focus search</button>
      <SearchInput ref={searchRef} placeholder="Search posts" />
    </header>
  );
}

In React 19, function components can receive ref as a normal prop. The original class component use case is no longer the center of React programming, and function components often need to pass refs through to real DOM nodes. So React now lets ref stay with the props for function components.

This does not mean React stopped handling refs. React still uses the ref to attach the parent reference to a DOM node or an imperative handle. The change is that a custom function component can now receive that ref through props.ref and decide where to pass it.

Conceptually, for function components, the ref can now arrive with the rest of the props.

// React 19, conceptually
{
  type: SearchInput,
  props: { placeholder: "Search posts", ref: searchRef },
  key: null,
}

For a small component like SearchInput, that makes the code easier to read.

import { useRef } from "react";

function SearchInput({
  ref,
  placeholder,
}: {
  ref: React.Ref<HTMLInputElement>;
  placeholder: string;
}) {
  return <input ref={ref} placeholder={placeholder} />;
}

export function FeedHeader() {
  const searchRef = useRef<HTMLInputElement>(null);

  return (
    <header>
      <button onClick={() => searchRef.current?.focus()}>Focus search</button>
      <SearchInput ref={searchRef} placeholder="Search posts" />
    </header>
  );
}

As you can see, the parent code did not change much. The improvement is inside the child component. We no longer need to wrap the component with forwardRef just to pass a ref to an internal input.

One thing did not change: key is still special. Unlike ref, key has no component-level meaning. It is only a hint React uses to match items in a list during reconciliation. A component should not need to read its own key, so React still keeps it out of normal props.

Optimistic UI with useOptimistic

When users submit something, they do not always want to wait for the server before seeing feedback. A common example is adding a comment under a post. We want the comment to appear immediately, then let the server confirm it in the background.

Before React 19, we usually managed this with temporary state.

import { useState } from "react";

type Comment = {
  id: string;
  message: string;
};

// Imagine this is where we save the comment to a database.
async function saveComment(message: string) {
  await new Promise((resolve) => setTimeout(resolve, 1000));
  return { id: crypto.randomUUID(), message };
}

export function CommentForm({ comments }: { comments: Comment[] }) {
  const [items, setItems] = useState(comments);
  const [message, setMessage] = useState("");

  async function submitComment() {
    const temporaryComment = { id: "pending", message };

    // Show the comment before the server confirms it.
    setItems((current) => [...current, temporaryComment]);

    // Save the comment to database.
    const savedComment = await saveComment(message);

    // Replace the temporary comment with the saved server comment.
    setItems((current) =>
      current.map((comment) =>
        comment.id === "pending" ? savedComment : comment,
      ),
    );

    setMessage("");
  }

  return (
    <>
      <input
        value={message}
        onChange={(event) => setMessage(event.target.value)}
      />
      <button onClick={submitComment}>Add comment</button>

      {items.map((comment) => (
        <p key={comment.id}>{comment.message}</p>
      ))}
    </>
  );
}

This works, but we are now responsible for temporary IDs, replacing pending items, and keeping the optimistic state in sync with the real state. React 19 adds useOptimistic for this pattern.

Before we look at useOptimistic, let’s quickly understand what a Transition is. A Transition is a non-blocking update. It tells React, “this update can happen in the background, so keep the UI responsive while working on it.”

For this example, we only need the standalone startTransition function from React.

import { startTransition, useState } from "react";

type Post = {
  id: string;
  title: string;
};

function FeedSearch({ posts }: { posts: Post[] }) {
  const [query, setQuery] = useState("");
  const [filter, setFilter] = useState("");

  const visiblePosts = posts.filter((post) => post.title.includes(filter));

  function updateQuery(nextQuery: string) {
    // Keep the input responsive.
    setQuery(nextQuery);

    // Let React render the expensive list update in the background.
    startTransition(() => {
      setFilter(nextQuery);
    });
  }

  return (
    <>
      {/* Urgent UI: this should update immediately as the user types. */}
      <input
        value={query}
        onChange={(event) => updateQuery(event.target.value)}
      />

      {/* Less urgent UI: React can interrupt this render if the query changes. */}
      <PostList posts={visiblePosts} />
    </>
  );
}

In the example above, setQuery(nextQuery) is urgent because it controls the input. We want the typed character to appear immediately. However, setFilter(nextQuery) may cause a large PostList to render, so we wrap it in startTransition to mark it as less urgent.

The function passed to startTransition still runs immediately. React does not wait before calling setFilter(nextQuery). The difference is that React treats this update as less urgent and therefore interruptible work. If the user types again while React is still rendering the filtered list, React can abandon the older render and start a newer one with the latest nextQuery.

This matters for useOptimistic because its setter must be called inside an Action. In this context, an Action simply means the function we pass to startTransition. That gives React a clear boundary: show the optimistic value while the async work is pending, then go back to the real value when that work finishes.

import { startTransition, useOptimistic, useState } from "react";

export function CommentForm({
  comments,
  refreshComments,
}: {
  comments: Comment[];
  refreshComments: () => Promise<void>;
}) {
  const [message, setMessage] = useState("");
  const [error, setError] = useState("");
  const [optimisticComments, addOptimisticComment] = useOptimistic(
    comments,
    (currentComments, message: string) => [
      ...currentComments,
      { id: "pending", message },
    ],
  );

  function submitComment() {
    const submittedMessage = message;

    setMessage("");
    setError("");

    startTransition(async () => {
      // Show the comment before the server confirms it.
      addOptimisticComment(submittedMessage);

      try {
        // Save the comment and refresh the real comments list.
        await saveComment(submittedMessage);
        await refreshComments();
      } catch {
        startTransition(() => {
          setError("Could not save comment.");
        });
      }
    });
  }

  return (
    <>
      <input
        value={message}
        onChange={(event) => setMessage(event.target.value)}
      />
      <button onClick={submitComment}>Add comment</button>
      {error && <p>{error}</p>}

      {optimisticComments.map((comment) => (
        <p key={comment.id}>{comment.message}</p>
      ))}
    </>
  );
}

In the example above, comments is still the real value. Think of it as the list we trust because it came from the server. The useOptimistic hook gives us another value, optimisticComments, which is the list we render on the screen.

Most of the time, optimisticComments is the same as comments. However, when we call addOptimisticComment(submittedMessage), React runs the update function we passed to useOptimistic.

(currentComments, message: string) => [
  ...currentComments,
  { id: "pending", message },
];

This function describes how the temporary UI should look. It takes the current optimistic list and returns a new list with the submitted comment added to the end. Since our UI renders optimisticComments, React can show that temporary comment while the real save request is still running.

The important part is that we call addOptimisticComment(submittedMessage) inside startTransition. This tells React that the temporary comment belongs to this save flow, so React knows when to show it and when to remove it. This gives React the missing context. It knows that the temporary comment belongs to the async work inside this Action: saving the comment and refreshing the real comments list. While that Action is pending, React can keep showing the optimistic value. When the Action finishes, React stops using the temporary optimistic layer and renders from the real comments value again.

💡 If we call a set function after an await, React currently needs another startTransition around that update to mark it as a Transition. That is why the catch block wraps setError in a second startTransition.

What if we call addOptimisticComment without startTransition?

async function submitComment() {
  const submittedMessage = message;

  // This is outside a Transition.
  addOptimisticComment(submittedMessage);

  await saveComment(submittedMessage);
  await refreshComments();
}

This looks close, but it breaks in two important ways.

The first problem is visibility. Without startTransition, there is no active Transition for React to attach this optimistic update to. React will log a warning in development about an optimistic state update outside a Transition. More importantly, useOptimistic is designed to show its temporary value only while a Transition is pending. With no pending Transition, React has no clear boundary to hold that optimistic value in place. Depending on when React processes its render queue, the temporary comment might flicker briefly and disappear, or it might not appear at all before the server responds.

The second problem is cleanup. When saveComment succeeds, the optimistic comment does not automatically give way to the real one, because React never received a tracked signal that the async work was in progress. When saveComment fails, the temporary comment stays stuck in the list. There is no Transition boundary for React to look at and say: “this pending value is done now, go back to the base state.” We would have to write our own cleanup logic for every success path and every failure path, tracking the temporary comment by hand and removing it ourselves. That is exactly the pattern we had before useOptimistic, and exactly what it is designed to replace.

In short, addOptimisticComment can only do its job when React knows the update belongs to an active Transition. Without startTransition, the temporary comment becomes an orphan: it may appear on screen, but React has no boundary to revert it from once the async work is done.

So the flow is like this.

  1. useOptimistic starts with the real comments value.
  2. It returns optimisticComments, which is the list we render on screen. When no update is pending, optimisticComments is identical to comments.
  3. The user clicks “Add comment”, which calls submitComment. This starts a Transition Action by passing an async function to startTransition. React immediately begins running the async function’s body, executing it synchronously from top to bottom until it hits the first await.
    1. addOptimisticComment(submittedMessage) is called. This function is synchronous, not async. It does not trigger a re-render on its own. It queues an optimistic state update and returns right away. At this point, React has not rendered anything yet. JavaScript is still running in the middle of the same function call.
    2. JavaScript moves into the try block and calls saveComment(submittedMessage).
    3. saveComment is an async function. When JavaScript reaches await saveComment(...), it suspends the async function and returns control to the browser’s event loop. This is the moment React processes the queued optimistic update and schedules a re-render. The browser paints the component using optimisticComments, and the temporary comment becomes visible on screen.
    4. To be precise about the order: the code already entered the try block and called saveComment before the temporary comment appeared. The optimistic render did not happen before saveComment was called. Instead, the render and the outgoing network request happen at the same time, concurrently.
    5. While the network request is in flight, the Transition is still pending and React keeps rendering optimisticComments with the temporary comment.
    6. When the server responds, saveComment resolves and JavaScript resumes. We call refreshComments() to fetch the updated comment list from the server.
  4. The Transition Action finishes after both saveComment and refreshComments have completed.
  5. useOptimistic switches back to using the real comments value, which is now updated with the saved comment.
  6. The temporary { id: "pending", message } object is discarded and no longer appears in optimisticComments.
  7. If the refreshed comments list includes the saved comment, the user now sees the real server version with its actual ID.

The temporary { id: "pending", message } object is never saved to the database. We only send the message to the server. React also does not compare the "pending" ID with the real server ID. The temporary comment and the saved comment only need a compatible shape because the same UI renders both of them. In our case, both need id and message.

If saveComment fails, refreshComments() does not run and the server list does not get a new comment. When the async work finishes, React stops showing the optimistic comment and renders the old comments list again. That is why the temporary comment disappears. In our example, we also show "Could not save comment." so the user knows what happened.

Compared to the previous example, we no longer manually keep a separate items state, remember which comment is temporary, or replace it after the request completes. We describe how the optimistic version should look, and React handles the temporary state for us.

Using <Context> as a Provider

Before React 19, Context providers used the .Provider property. This was never a big problem, but provider-heavy apps can quickly become visually noisy.

import { createContext } from "react";

const ThemeContext = createContext("light");

export function SocialApp() {
  return (
    <ThemeContext.Provider value="dark">
      <FeedPage />
    </ThemeContext.Provider>
  );
}

This syntax is not difficult, but it is a little noisy. React 19 lets us render the Context object itself as the provider.

import { createContext } from "react";

const ThemeContext = createContext("light");

export function SocialApp() {
  return (
    <ThemeContext value="dark">
      <FeedPage />
    </ThemeContext>
  );
}

The behavior is the same. Components below ThemeContext can read the value with useContext(ThemeContext) or use(ThemeContext).

This is an ergonomic improvement, not a behavioral one. However, small syntax improvements matter when the root of an app contains theme, logged-in user, router, feature flag, and data providers all stacked together like a very polite sandwich.

Using useDeferredValue with an Initial Value

Search inputs have one simple rule: the text box should never feel slow. Even if the result list is large, the user should be able to type without waiting for React to finish rendering every matching item.

This is where useDeferredValue helps. It lets us keep one value urgent and make another value slightly delayed. In our case, query controls the input, so it should update immediately. deferredQuery controls the expensive feed results, so it can lag behind a little.

Before React 19, the deferred value started with the current value.

import { useDeferredValue, useState } from "react";

type Post = {
  id: string;
  text: string;
};

function getFilteredPosts(posts: Post[], query: string) {
  console.log("filtering posts for:", query);

  return posts.filter((post) => post.text.includes(query));
}

function PostSearch({ posts }: { posts: Post[] }) {
  const [query, setQuery] = useState("vacation");
  const deferredQuery = useDeferredValue(query);

  const visiblePosts = getFilteredPosts(posts, deferredQuery);

  return (
    <>
      {/* Urgent UI: this stays responsive while the user types. */}
      <input value={query} onChange={(event) => setQuery(event.target.value)} />

      {/* Deferred UI: this can lag behind if filtering is expensive. */}
      {visiblePosts.map((post) => (
        <p key={post.id}>{post.text}</p>
      ))}
    </>
  );
}

In the example above, the input uses query, but the result list uses deferredQuery. When the user types, query updates first so the input stays responsive. React can then update deferredQuery and render the filtered posts slightly later.

This is part of React’s concurrent rendering model. useDeferredValue does not wait for a fixed number of milliseconds. It does not work like debounce or throttle. Instead, React first renders the urgent update with the latest query, while deferredQuery can keep showing the older value. Then React starts a background render where deferredQuery catches up.

If the user types again before that background render finishes, React can throw away the older background render and restart with the latest value. So React does not make filtering faster, but it prevents the slow result list from blocking the input.

💡 To be precise, React does not interrupt a JavaScript function in the middle of execution. If getFilteredPosts has already started running, that synchronous function will finish. The interruptible part is React’s background render work. React can decide not to commit an older background render, or restart the deferred render with the latest deferredQuery before showing it on the screen. This is why render functions should stay pure and avoid side effects, even if they are called more than once.

However, there is one catch. On the first render, deferredQuery is also "vacation". So the first log looks like this.

filtering posts for: vacation

If getFilteredPosts(posts, "vacation") is expensive, React still has to run that filtering logic during the first render. That means the browser may spend time filtering and rendering the list before the page becomes responsive.

But what changed in React 19? The useDeferredValue hook now accepts an initial value. This gives React a cheaper value to use for the first deferred render.

import { useDeferredValue, useState } from "react";

function getFilteredPosts(posts: Post[], query: string) {
  console.log("filtering posts for:", query);

  // This makes the initial deferred value cheap to render.
  if (query === "") {
    return [];
  }

  return posts.filter((post) => post.text.includes(query));
}

function PostSearch({ posts }: { posts: Post[] }) {
  const [query, setQuery] = useState("vacation");
  const deferredQuery = useDeferredValue(query, "");

  const visiblePosts = getFilteredPosts(posts, deferredQuery);

  return (
    <>
      {/* Urgent UI: this still shows "vacation" immediately. */}
      <input value={query} onChange={(event) => setQuery(event.target.value)} />

      {/* Deferred UI: this starts with the initial deferred value. */}
      {visiblePosts.map((post) => (
        <p key={post.id}>{post.text}</p>
      ))}
    </>
  );
}

In this version, the input still shows "vacation" immediately because the input is controlled by query. But the feed results first render with deferredQuery set to "". So the first log looks like this.

filtering posts for:

Since getFilteredPosts returns an empty list immediately for "", React can skip the expensive filtering work during the first render. After that, React schedules the deferred update and catches up to the real query value. Then we see another log.

filtering posts for: vacation

This is the important difference. Without an initial value, the expensive list starts with "vacation" immediately. With an initial value, the expensive list starts with "", which is cheap in our implementation, then updates to "vacation" after React schedules the deferred work.

So the initial value does not change what the user typed. It only changes what the deferred part of the UI sees during its first render.

💡 If you are wondering why we would not just use debounce, the difference is that debounce delays when a value changes. useDeferredValue lets the value change immediately, then tells React that some UI depending on that value can render later. This keeps React in control of rendering priority instead of making us guess a timeout value.

This improves React rendering because urgent UI, like the input, does not have to wait for less urgent UI, like a large filtered list. The list may lag behind for a moment, but the page feels responsive because React can prioritize what the user is directly interacting with.

Preserving Hidden UI with <Activity />

Most social apps have sections users move between often, such as feed, messages, and notifications. The common React pattern is to render only the active section. This is simple, but it also means the inactive section is not just hidden. It is removed from the tree.

Let’s say our notifications panel has a filter input. Before React 19.2, if we wanted to preserve that filter while conditionally rendering sections, we usually had to lift the filter state into the parent.

import { useState } from "react";

function NotificationsPanel({
  filter,
  setFilter,
}: {
  filter: string;
  setFilter: (value: string) => void;
}) {
  return (
    <section>
      <input
        value={filter}
        onChange={(event) => setFilter(event.target.value)}
        placeholder="Filter notifications"
      />
      <p>Showing notifications for: {filter}</p>
    </section>
  );
}

function SocialSections() {
  const [activeSection, setActiveSection] = useState("feed");
  const [notificationFilter, setNotificationFilter] = useState("");

  return (
    <>
      <button onClick={() => setActiveSection("feed")}>Feed</button>
      <button onClick={() => setActiveSection("notifications")}>
        Notifications
      </button>

      {activeSection === "feed" && <FeedPanel />}
      {activeSection === "notifications" && (
        <NotificationsPanel
          filter={notificationFilter}
          setFilter={setNotificationFilter}
        />
      )}
    </>
  );
}

In the example above, NotificationsPanel still unmounts when we switch back to the feed. However, the filter value survives because it now lives in SocialSections, not inside NotificationsPanel.

This works, but now the parent owns state that really belongs to the notifications panel. Another workaround was to keep both sections mounted and hide one with CSS. That also works, but the hidden UI still behaves like active UI. Its Effects keep running, which can be a problem if the hidden section subscribes to data or performs background work.

React 19.2 introduces <Activity /> for this pattern. Instead of conditionally mounting and unmounting a section, we keep it inside an Activity boundary and change its mode.

import { Activity, useEffect, useState } from "react";

function NotificationsPanel() {
  const [filter, setFilter] = useState("");

  useEffect(() => {
    console.log("NotificationsPanel effect mounted");

    return () => {
      console.log("NotificationsPanel effect cleaned up");
    };
  }, []);

  return (
    <section>
      <input
        value={filter}
        onChange={(event) => setFilter(event.target.value)}
        placeholder="Filter notifications"
      />
      <p>Showing notifications for: {filter}</p>
    </section>
  );
}

export function SocialSections() {
  const [activeSection, setActiveSection] = useState("feed");

  return (
    <>
      <button onClick={() => setActiveSection("feed")}>Feed</button>
      <button onClick={() => setActiveSection("notifications")}>
        Notifications
      </button>

      <Activity mode={activeSection === "feed" ? "visible" : "hidden"}>
        <FeedPanel />
      </Activity>

      <Activity mode={activeSection === "notifications" ? "visible" : "hidden"}>
        <NotificationsPanel />
      </Activity>
    </>
  );
}

Now, when users switch away from notifications and come back, the filter input is still there. This happens because the hidden Activity preserves the component’s state and DOM.

The Effect behavior is different. When the notifications Activity becomes hidden, React cleans up the Effect. So we would see this log.

NotificationsPanel effect cleaned up

When the notifications Activity becomes visible again, React runs the Effect again.

NotificationsPanel effect mounted

So the simple rule is this: hidden Activity preserves state and DOM, but Effects do not keep running while it is hidden. If an Effect starts a subscription, timer, or API polling loop, React runs its cleanup when the Activity is hidden. When the Activity becomes visible again, React runs the Effect again.

The DOM part is worth calling out. React hides an Activity by applying display: none, so the DOM nodes are still there even though the user cannot see them. This is great for preserving things like input text, scroll position, or a media timecode. However, DOM-level side effects can still surprise us. For example, a hidden <video> may need explicit cleanup if preserving the DOM also preserves something the browser keeps doing. The React docs have a troubleshooting section for this: My hidden components have unwanted side effects.

💡 This is why <Activity /> is different from manually keeping a component mounted and hiding it with CSS. CSS can hide the DOM, but React still treats that component as fully active, so Effects keep running. Activity tells React: preserve this UI state, but pause its Effects while it is hidden.

This makes <Activity /> useful for screens users are likely to return to, such as feed sections, notification panels, message threads, and back-navigation flows.

Separating Effect Events with useEffectEvent

Effects are easy to write until they start doing two different jobs at once. One part of the Effect may be truly reactive, meaning it should rerun when a value changes. Another part may only need the latest value when something happens.

A common example is a chat connection. The connection should depend on threadId, because changing the thread means we need a new connection. However, the notification shown after connecting may depend on the current theme. Changing the theme should not reconnect the chat. It should only change how the next notification looks.

Before React 19.2, we might write this.

import { useEffect } from "react";

function ChatStatus({
  threadId,
  theme,
}: {
  threadId: string;
  theme: "light" | "dark";
}) {
  useEffect(() => {
    const connection = createConnection(threadId);

    connection.on("connected", () => {
      showNotification("Connected!", theme);
    });

    connection.connect();

    return () => connection.disconnect();
  }, [threadId, theme]);

  return <p>Chat thread: {threadId}</p>;
}

The dependency array is technically correct. We use both threadId and theme, so both are listed. However, changing the theme now reconnects the chat thread. That is not what we want.

Some developers solved this by removing theme from the dependency array and ignoring the lint warning. Let me explain why this approach could be problematic. The notification callback would then read a value that React does not know about. That might work today, but if the Effect changes later, the missing dependency can become a real bug.

React 19.2 adds useEffectEvent for event-like logic that runs from an Effect.

import { useEffect, useEffectEvent } from "react";

function ChatStatus({
  threadId,
  theme,
}: {
  threadId: string;
  theme: "light" | "dark";
}) {
  const onConnected = useEffectEvent(() => {
    showNotification("Connected!", theme);
  });

  useEffect(() => {
    const connection = createConnection(threadId);

    connection.on("connected", () => {
      onConnected();
    });

    connection.connect();

    return () => connection.disconnect();
  }, [threadId]);

  return <p>Chat thread: {threadId}</p>;
}

Now the Effect only depends on threadId, because threadId controls the connection itself. The onConnected Effect Event still sees the latest theme, but it does not force the connection Effect to run again.

So if threadId changes, React disconnects from the old thread and connects to the new one. That is correct because the connection itself changed. If theme changes, React does not reconnect. That is also correct because the connection did not change. The next time the "connected" event fires, onConnected still reads the latest theme.

You can think of useEffectEvent as creating a stable function whose body is refreshed by React on every render. The function identity does not force the Effect to rerun, but when the function is called later, it reads the latest props and state from the most recent render.

In our example, the Effect subscribes to the "connected" event once per threadId. The callback registered with the connection calls onConnected(). When that happens, React runs the latest version of the Effect Event body, so showNotification("Connected!", theme) uses the current theme, not the old one from the render where the connection was created.

This is the main idea behind useEffectEvent: separate reactive Effect logic from event-like logic inside the Effect.

💡 useEffectEvent is not a general replacement for event handlers like onClick. It is also not a way to avoid dependencies just because an Effect reruns too often. If a value controls the Effect itself, such as threadId controlling which chat connection to create, keep it in the dependency array. Use useEffectEvent only for logic called from inside an Effect, such as a subscription callback, timer callback, or connection event that needs the latest props or state.

Conclusion

React 19 and 19.2 do not ask us to forget everything we know about React. Instead, they clean up patterns we were already writing.

The use hook gives React a built-in way to read render-time resources. ref as a prop removes the need for forwardRef in many simple components. useOptimistic gives optimistic UI a dedicated API. <Context> as a provider makes provider trees cleaner. useDeferredValue with an initial value gives expensive UI a cheaper first render. <Activity /> preserves hidden UI state. useEffectEvent lets Effects keep correct dependencies without reconnecting things unnecessarily.

Individually, these changes look small. Together, they make everyday React code feel less like plumbing and more like UI.

#react #hooks #web-development