umma.dev

React 19.2

New React features that are going to make it easier to build web apps… And more chatbots πŸ™„

<Activity />: Controlled rendering of β€˜activities’

The <Activity /> component let’s you group parts of the UI and decide whether they should be active or hidden.

This feature is useful for handling animations and loading states, rendering data and various elements within a layout.

visible

  • Children render normally, effects run, updates happen immediately
  • When the boundary is visible, the children from the previous state are restored and recreate the effects

hidden

  • Hides the children, unmounts effect and defers all updates until there is nothing left
  • Children will be hidden with display: "none" CSS property
  • A clean up will happen and while hidden, the children will still re-render in response to new props but at a lower priority than the rest of the content

Hide off screen UI but keep its state

import { useState, Activity } from "react";

export default function Page() {
  const [showFilters, setShowFilters] = useState(false);

  return (
    <>
      <button onClick={() => setShowFilters((v) => !v)}>
        {showFilters ? "Hide" : "Show"} filters
      </button>

      <Activity mode={showFilters ? "visible" : "hidden"}>
        <FiltersPanel />
      </Activity>
    </>
  );
}

Background-prep the next route

import { Activity, startTransition, use } from "react";
import { fetchProduct } from "./data";

export default function ProductShell({ nextId }) {
  function warmNext() {
    startTransition(() => {
      use(fetchProduct(nextId)); // data loads in background
    });
  }

  return (
    <>
      <button onClick={warmNext}>Preload next</button>
      <Activity mode="hidden">
        <ProductPage id={nextId} />
      </Activity>
    </>
  );
}

Stable layout sidebars

<Activity mode={open ? "visible" : "hidden"}>
  <aside className="w-72 shrink-0">
    <Sidebar />
  </aside>
</Activity>
<main className="min-w-0">
  <Content />
</main>

useEffectEvent: Fixing stale closures in effects

An effect usually depends on props and this means having to restart it every time your props change. We can now define an event-like callback, that always sees the latest props/state but doesn’t need to be in the effect’s dependency list (which is great, as useEffect was taking it’s toll).

Fixing stale subscriptions

import { useEffect, useEffectEvent } from "react";
import { socket } from "./socket";

export default function Chat({ theme }) {
  const onMessage = useEffectEvent((msg) => {
    notifyUser(msg, { theme }); // always latest theme
  });

  useEffect(() => {
    function handler(msg) {
      onMessage(msg);
    }
    socket.on("message", handler);
    return () => socket.off("message", handler);
  }, []); // no need to list theme
}

Intervals without restarts

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

export default function Clock({ format }) {
  const [now, setNow] = useState(() => new Date());

  const tick = useEffectEvent(() => {
    setNow(new Date());
    logHeartbeat(format); // stays fresh
  });

  useEffect(() => {
    const id = setInterval(() => tick(), 1000);
    return () => clearInterval(id);
  }, []);

  return <time>{formatTime(now, format)}</time>;
}

Async effect safety

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

export function Search({ query }) {
  const [results, setResults] = useState([]);

  const onResolve = useEffectEvent((data) => setResults(data));

  useEffect(() => {
    let cancelled = false;
    fetch(`/api/search?q=${encodeURIComponent(query)}`)
      .then((a) => a.json())
      .then((b) => {
        if (!cancelled) onResolve(b);
      });
    return () => {
      cancelled = true;
    };
  }, [query]);

  return <Results items={results} />;
}

cacheSignal: Abort work when rendering ends

For server components, this version of react ensures we can abort cached operations automatically when rendering finishes, fails or is cancelled.

Abort in-flight fetches

import { cache, cacheSignal } from "react";

const getProduct = cache(async (id) => {
  const res = await fetch(`https://api.example.com/products/${id}`, {
    signal: cacheSignal(),
    cache: "no-store",
  });
  return res.json();
});

export default async function ProductPage({ id }: { id }) {
  const product = await getProduct(id);
  return <ProductView product={product} />;
}

Custom AbortController

import { cache, cacheSignal } from "react";

const expensive = cache(async () => {
  const ctrl = new AbortController();
  cacheSignal().addEventListener("abort", () => ctrl.abort());
  return fetch("https://api.example.com/big", { signal: ctrl.signal }).then(
    (r) => r.json()
  );
});

Server Rendering: Partial pre-rendering and resume

Render part of your app now, ship it and resume later.

Web streams

import { prerender, resume } from "react-dom/server.edge";
import App from "./App";

const { prelude, postponed } = await prerender(<App />);
await sendHtml(prelude); // send static shell fast
const stream = await resume(postponed);
return new Response(stream, { headers: { "content-type": "text/html" } });

Node streams

import { prerender, resumeToPipeableStream } from "react-dom/server";
import App from "./App";

const { prelude, postponed } = await prerender(<App />);

res.setHeader("Content-Type", "text/html");
res.write(prelude);

const stream = await resumeToPipeableStream(postponed, {
  onAllReady() {
    stream.pipe(res);
  },
});

Batched Suspense reveal

import { Suspense } from "react";
export default function App() {
  return (
    <>
      <Hero />
      <Suspense fallback={<Skeleton />}>
        <Products />
        <Reviews />
      </Suspense>
    </>
  );
}

Small Upgrades

  • eslint-plugin-react-hooks@6.1.0: flat config is now recommended
// eslint.config.js
import reactHooks from "eslint-plugin-react-hooks";
export default [reactHooks.configs["recommended"]];
  • useId prefix change: IDs now look like _s_510, valid for XML and view-transition APIs
const id = useId();
<label htmlFor={id}>Email</label>
<input id={id} />