React NotesRohit’s interview study guide
Chapter 12

React 19 Features

What React 19 (December 2024) added and removed, and what 19.1, 19.2 and 19.3 (September 2026) added on top: Actions, useActionState, form actions, useOptimistic, use(), document metadata, and the upgrade traps.

9 topics
Parts marked Advanced are extra depth. Skip them on a first read or a quick revision.

#What changed in React 19

Added

React 19 is the biggest release since hooks, but most of it follows from one idea: Actions. React 18 gave us transitions for rendering; React 19 extends them to async work. A function that does async work inside a transition is an Action, and React tracks its pending state, errors, optimistic updates and form resets for you. useActionState, useOptimistic, useFormStatus and <form action={fn}> are all built on that.

Alongside Actions came a set of "finally" fixes that remove boilerplate: ref is a plain prop (no more forwardRef), <Context> is its own provider, ref callbacks can return a cleanup, <title> and <meta> can live in any component, and Server Components became stable. And a clean-up of old APIs: ReactDOM.render, string refs, propTypes and defaultProps on functions are gone.

Mental model for interviews: React 18 was about when React renders (concurrency); React 19 is about how you mutate data (Actions) and removing old ceremony.

WhatsNew.jsx
import { createContext, useContext, useRef } from "react";

const ThemeContext = createContext("light");

// React 19: ref is a normal prop on function components.
function TextInput({ label, ref }) {
  return <input aria-label={label} ref={ref} />;
}

function ThemedLabel() {
  const theme = useContext(ThemeContext);
  return <p>Theme: {theme}</p>;
}

export function Form({ log }) {
  const inputRef = useRef(null);
  return (
    // React 19: <ThemeContext> is the provider (was <ThemeContext.Provider>).
    <ThemeContext value="dark">
      <ThemedLabel />
      <TextInput label="Name" ref={inputRef} />
      <button onClick={() => inputRef.current.focus()}>Focus</button>
      <div
        ref={(node) => {
          log.push("ref attached");
          return () => log.push("ref cleanup"); // React 19: ref callbacks can return a cleanup
        }}
      />
    </ThemeContext>
  );
}
What’s happening
  1. <ThemeContext value="dark"> renders the context object itself as a provider. In React 18 that had to be <ThemeContext.Provider value="dark">; .Provider still works in 19 and the React team says it will be deprecated later. ThemedLabel reads it with useContext → "Theme: dark".
  2. TextInput takes ref straight from its props and puts it on the <input>. In React 18, ref was stripped from props, so this needed forwardRef((props, ref) => …). The test clicks "Focus" and the input has focus, so inputRef.current really is the DOM input. (forwardRef still works in 19.3; a second test checks it.)
  3. The callback ref on the <div> runs on mount (ref attached) and returns a function. React 19 treats that as a cleanup and calls it on unmount (ref cleanup) instead of calling the ref with null as React 18 did. That's why TypeScript now rejects implicit returns from ref callbacks (ref={el => (x = el)}): the returned value would be mistaken for a cleanup.
  4. None of this needs a new way of thinking; it's the same React with less ceremony.

The headline list (React 19.0, December 5, 2024):

AreaWhat's new
ActionsAsync functions in startTransition, <form action={fn}>, formAction on buttons
New hooksuseActionState, useOptimistic, useFormStatus (react-dom), use()
Refsref as a prop, ref cleanup functions
Context<Context> as provider
Document<title>, <meta>, <link> hoisted to <head>; stylesheets with precedence; async scripts deduplicated; preload/preinit/preconnect/prefetchDNS
ServerServer Components and Server Functions ('use server') stable; react-dom/static prerender
ErrorsOne log per error instead of three; onCaughtError/onUncaughtError root options; hydration mismatch errors show a diff
OtheruseDeferredValue(value, initialValue), full Custom Elements support
RemovedReactDOM.render/hydrate, string refs, legacy context, propTypes/defaultProps on functions, createFactory, UMD builds

#Actions and async transitions

Added

An Action is a function that performs an async side effect (save, delete, submit) and is run inside a transition. In React 18, startTransition only accepted synchronous functions. React 19 accepts async ones and keeps isPending true until the whole async function finishes. Because the work is a transition, the current UI stays responsive, and state updates made during the action are applied together at the end.

The problem it solves is the pending/error boilerplate everyone wrote for every button that talks to a server: setIsPending(true), try, await, setIsPending(false), catch, setError, and don't forget double submits. Mental model: an Action is a transaction wrapped around an async function. React opens it (pending), runs your code, and closes it (commits the result) when the promise settles.

UpdateName.jsx
import { useState, useTransition } from "react";

// React 18 and earlier: pending and error state by hand.
function UpdateNameOld() {
  const [name, setName] = useState("");
  const [error, setError] = useState(null);
  const [isPending, setIsPending] = useState(false);

  async function handleSubmit() {
    setIsPending(true);
    const error = await updateName(name);
    setIsPending(false);
    if (error) {
      setError(error);
      return;
    }
    setError(null);
  }

  return (
    <div>
      <input aria-label="Name" value={name} onChange={(e) => setName(e.target.value)} />
      <button onClick={handleSubmit} disabled={isPending}>Update</button>
      {error && <p role="alert">{error}</p>}
    </div>
  );
}

// React 19: an async transition (an "Action") tracks pending for you.
function UpdateNameNew() {
  const [name, setName] = useState("");
  const [error, setError] = useState(null);
  const [isPending, startTransition] = useTransition();

  function handleSubmit() {
    startTransition(async () => {
      const error = await updateName(name);
      startTransition(() => {
        setError(error); // a state update after await: wrap it again
      });
    });
  }

  return (
    <div>
      <input aria-label="Name" value={name} onChange={(e) => setName(e.target.value)} />
      <button onClick={handleSubmit} disabled={isPending}>Update</button>
      {error && <p role="alert">{error}</p>}
    </div>
  );
}
What’s happening
  1. Both versions pass the same test: click "Update" with an empty name → the button is disabled while updateName runs (20 ms) → "Name is required" → the button is enabled again. Type "Rohit", click again → disabled → the error disappears.
  2. In the old version you own three pieces of state and the order of updates. Forget setIsPending(false) on one path (an early return, an exception) and the button stays disabled forever.
  3. In the new version, startTransition(async () => …) sets isPending to true immediately (an urgent render, like any transition) and keeps it true until the async function's promise settles. There's no setIsPending anywhere.
  4. The inner startTransition around setError is the one rough edge. React can only tell that a setState belongs to a transition while the transition's callback is running synchronously; after an await, that context is lost (JavaScript has no async context React can use yet). The React docs say to wrap post-await updates in another startTransition so they're batched into the Action and committed together with isPending turning false.
  5. If the async function throws, the transition still ends (isPending goes back to false) and the error is rethrown during render to the nearest error boundary. For expected errors (validation), return them as values as above; for unexpected ones, let the boundary handle it.

Why it's a transition and not just "async with a flag":

  • Non-blocking: state updates inside it are low priority, so typing elsewhere stays responsive.
  • Batched: updates made during the Action are committed together when it finishes, so you don't see half-applied results.
  • Composable: useOptimistic only works inside a transition (it shows a temporary value while the Action is pending), and <form action> runs your function as an Action automatically.

Naming convention from the React team: call functions that are used as Actions somethingAction (saveAction, deleteAction), and props that accept them action or xxxAction, so it's clear they'll run in a transition.

#useActionState

useActionState turns an Action into state: you give it an action function and an initial state; it gives you back the latest state, a wrapped action to call (or pass to a form), and isPending. Each time the action runs, its return value becomes the new state.

JavaScript
const [state, formAction, isPending] = useActionState(actionFn, initialState, permalink?);
// actionFn(previousState, payload) → nextState (sync or async)

Mental model: it's useReducer for async work. A reducer takes (state, action) and returns the next state; useActionState's function takes (previousState, payload) and returns (or resolves to) the next state, and it may have side effects, like a network request. The "payload" is whatever you pass when calling it; when it's a form's action, it's the form's FormData.

My notes called it "kinda similar to TanStack Query", and showed const { execute, pending, error } = useActionState(saveUserData). That API never existed (see the correction below). Here's the same form with the real API:

UserForm.jsx
import { useActionState } from "react";

// The action: (previous state, form data) → next state. Errors are returned, not thrown.
async function saveUser(prevState, formData) {
  const userData = { name: formData.get("name"), email: formData.get("email") };
  const response = await fetch("/api/save-user", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(userData),
  });
  if (!response.ok) {
    return { ...prevState, error: `Save failed (HTTP ${response.status})` };
  }
  return { user: await response.json(), error: null };
}

export function UserForm() {
  const [state, formAction, isPending] = useActionState(saveUser, { user: null, error: null });

  return (
    <div>
      <form action={formAction}>
        <input name="name" aria-label="Name" defaultValue={state.user?.name ?? ""} />
        <input name="email" aria-label="Email" type="email" />
        <button type="submit" disabled={isPending}>
          {isPending ? "Saving..." : "Save"}
        </button>
      </form>
      {state.error && <div role="alert">Error: {state.error}</div>}
      {state.user && <p>Saved {state.user.name}</p>}
    </div>
  );
}
What’s happening
  1. First render: state is the initial { user: null, error: null }, isPending is false. The inputs are uncontrolled (name attributes, no value/onChange): the form's FormData will carry their values, so the useState for the fields in my notes isn't needed.
  2. The user types "Rohit" and "r@example.com" and submits. Because action is a function, React calls formAction with the form's FormData inside a transition (no preventDefault needed). isPending becomes true: the button shows "Saving..." and is disabled.
  3. React calls saveUser(previousState, formData). It reads the fields with formData.get(...) and POSTs { name: "Rohit", email: "r@example.com" } (the test checks the body).
  4. The server returns the saved user; saveUser returns { user, error: null }. That becomes state, isPending goes back to false, and "Saved Rohit" renders.
  5. After a successful form action, React resets the form's uncontrolled fields: the test sees the email field back to "". The name field shows "Rohit" because its defaultValue now comes from state.user.name, and a reset restores the default value.
  6. On a 500, saveUser returns { ...prevState, error: "Save failed (HTTP 500)" }, so the error renders and the previous user is kept. Returning errors as state (instead of throwing) is the recommended pattern for expected failures; a thrown error would go to the nearest error boundary and cancel any queued actions.
AdvancedCalling it without a form, and the action queue

You don't have to use a form. Call the dispatch function yourself, inside a transition:

React
const [count, dispatch, isPending] = useActionState(async (prev, amount) => {
  await new Promise((r) => setTimeout(r, 10)); // pretend to save
  return prev + amount;
}, 0);

startTransition(() => {
  dispatch(1);
  dispatch(10);
  dispatch(100);
});
// action calls: prev=0 +1, prev=1 +10, prev=11 +100 → count is 111
What’s happening
  1. Three dispatches are made in one go. useActionState queues them: the second doesn't start until the first has finished, because it needs the first one's result as its prev.
  2. The test logs each call's arguments: prev=0 +1, then prev=1 +10, then prev=11 +100. The final state is 111.
  3. This is the reducer model at work: actions are applied in order, each to the result of the last. The flip side is that a slow action delays everything queued behind it; for instant feedback, pair it with useOptimistic.
  4. Called outside a transition (dispatch() from a plain event handler), React 19.3 logs: "An async function with useActionState was called outside of a transition. This is likely not what you intended (for example, isPending will not update correctly). Either call the returned function inside startTransition, or pass it to an action or formAction prop."

When to use it: forms and buttons whose result you want to show (a success message, a validation error list, the saved record) and whose pending state drives the UI. It's especially natural with Server Functions in Next.js, where the action runs on the server and the optional permalink argument lets the form work before JavaScript loads (progressive enhancement). When not to: reading data (use a query library or use()), or complex cache updates across the app (TanStack Query's useMutation + invalidation).

#Form actions and useFormStatus

Added

React 19 lets you pass a function to <form action> (and to formAction on a <button> or <input type="submit">). On submit, React calls it with the form's FormData, runs it as an Action (a transition), and resets the form after it succeeds. No onSubmit, no event.preventDefault(), no controlled inputs needed for simple forms.

useFormStatus (from react-dom) lets a component inside a form read that form's submission status: { pending, data, method, action }. That's what lets a design-system <SubmitButton> disable itself and show a spinner without the form passing it props.

Mental model: the <form> is a provider and useFormStatus is its consumer, like context: it reports on the nearest parent <form>, and only that.

Newsletter.jsx
import { startTransition, useState } from "react";
import { useFormStatus } from "react-dom";

function SubmitButton() {
  const { pending, data } = useFormStatus(); // the status of the <form> this button is inside
  return (
    <button type="submit" disabled={pending}>
      {pending ? `Subscribing ${data.get("email")}…` : "Subscribe"}
    </button>
  );
}

export function Newsletter({ subscribe }) {
  const [message, setMessage] = useState("");

  async function subscribeAction(formData) {
    const email = formData.get("email");
    await subscribe(email);
    startTransition(() => setMessage(`Subscribed ${email}`)); // state update after await
  }

  return (
    <form action={subscribeAction}>
      <input name="email" aria-label="Email" />
      <SubmitButton />
      <p>{message}</p>
    </form>
  );
}
What’s happening
  1. The user types "a@b.com" and clicks the button. React builds a FormData from the form and calls subscribeAction(formData) inside a transition.
  2. While subscribe is pending, useFormStatus() in SubmitButton returns pending: true and data (the submitted FormData). The button reads "Subscribing a@b.com…" and is disabled, all without any props from Newsletter. The input still contains "a@b.com" at this point.
  3. subscribe resolves; the message is set (in a transition, since it's after an await) → "Subscribed a@b.com".
  4. The action finished without throwing, so React resets the form: the test sees the email input back to "". Controlled inputs (with value from state) aren't reset; you'd clear the state yourself. To reset at another time, call requestFormReset(formElement) from react-dom inside a transition.
  5. The button's label goes back to "Subscribe" because pending is false again.

Things that are easy to miss:

  • formAction on a button overrides the form's action for that button, so one form can have "Save" and "Save as draft" doing different things.
  • Progressive enhancement: with a framework and a Server Function as the action, the form works as a normal HTML POST before hydration.
  • Validation: native attributes (required, type="email") still run before the action. For richer rules use useActionState and return field errors, or React Hook Form (see Forms with React 19 actions).
  • onSubmit still works and is still right when you need preventDefault logic or a library that expects it.
  • 19.3 changes: onReset now fires when React auto-resets a form after a Server Action, submit events include the submitter, and form status no longer resets when component state is updated (from the 19.3 changelog).

#useOptimistic

Added

useOptimistic shows a temporary value while an Action is pending, so the UI can reflect what the user did immediately ("Sending…" messages, a filled heart, an item in a list) instead of waiting for the server. When the Action finishes, the temporary value is thrown away automatically and the real state shows: the confirmed value on success, the original value on failure.

JavaScript
const [optimisticState, setOptimistic] = useOptimistic(actualState, reducer?);

Mental model: an overlay on top of your real state that exists only for the lifetime of the Action. You never "undo" it; it simply stops existing when the transition ends. Compare TanStack Query's version (see Optimistic updates), where you snapshot the cache and write the rollback yourself.

Thread.jsx
import { startTransition, useOptimistic, useState } from "react";

export function Thread({ send }) {
  const [messages, setMessages] = useState([{ id: 1, text: "Hi!" }]);
  const [error, setError] = useState(null);
  const [optimisticMessages, addOptimistic] = useOptimistic(
    messages,
    (current, text) => [...current, { id: `temp-${text}`, text, sending: true }],
  );

  async function sendAction(formData) {
    const text = formData.get("text");
    addOptimistic(text); // shows immediately, only while this action is pending
    try {
      const saved = await send(text);
      startTransition(() => {
        setMessages((prev) => [...prev, saved]);
        setError(null);
      });
    } catch {
      startTransition(() => setError(`Couldn't send "${text}"`));
    }
  }

  return (
    <>
      <ul>
        {optimisticMessages.map((m) => (
          <li key={m.id}>
            {m.text}
            {m.sending && <small> (sending…)</small>}
          </li>
        ))}
      </ul>
      {error && <p role="alert">{error}</p>}
      <form action={sendAction}>
        <input name="text" aria-label="Message" />
        <button type="submit">Send</button>
      </form>
    </>
  );
}
What’s happening
  1. Before anything is sent, optimisticMessages is just messages: ["Hi!"].
  2. Submit "Hello": the form runs sendAction as an Action. addOptimistic("Hello") calls the reducer with the current list and the text, so optimisticMessages becomes ["Hi!", "Hello (sending…)"] immediately. The test sees that before send has resolved. messages itself is unchanged.
  3. send resolves with { id: 2, text: "Hello" }. setMessages adds it to the real state. When the Action ends, React drops the optimistic overlay and optimisticMessages is messages again: ["Hi!", "Hello"]. The swap happens in one render, so there's no frame with both the temporary and the real item.
  4. Failure path: send rejects, the catch sets an error, and the real messages never changed. When the Action ends, the overlay disappears by itself: the list goes back to ["Hi!"] and the alert says Couldn't send "Oops". There's no rollback code.
  5. The reducer form (second argument) is useful when the optimistic change depends on the current value. If messages changes while the Action is pending (another message arrives), React re-runs the reducer on the new list, so the optimistic item is applied on top of the latest data.

Where it fits: likes and bookmarks, chat messages, toggles, renaming, adding to a list: anything that almost always succeeds and where waiting feels sluggish. Pair it with useActionState when the server result is also state (setOptimistic then dispatchAction in the same transition). Avoid it where the server decides the outcome (payments, generated ids the next screen needs).

#use()

Added

use is a React 19 API for reading a resource during render: a promise (suspends until it resolves) or a context (like useContext). It's called use, not useSomething, because it isn't a normal hook: it can be called conditionally, inside if blocks and loops and after early returns. It still has to be called during render, in a component or a hook.

Mental model: use(promise) is await for components. The component's code reads as if the value is already there; React handles the waiting with the nearest <Suspense> and errors with the nearest error boundary.

UseDemo.jsx
import { createContext, Suspense, use } from "react";

export const ThemeContext = createContext("light");

// use(context) may be called after an early return or inside a condition.
export function Heading({ children }) {
  if (children == null) return null;
  const theme = use(ThemeContext);
  return <h1 className={theme}>{children}</h1>;
}

// use(promise): the promise is created by the caller and passed down.
function Comments({ commentsPromise }) {
  const comments = use(commentsPromise);
  return <ul>{comments.map((c) => <li key={c.id}>{c.text}</li>)}</ul>;
}

export function Post({ commentsPromise }) {
  return (
    <article>
      <Heading>My post</Heading>
      <Suspense fallback={<p>Loading comments…</p>}>
        <Comments commentsPromise={commentsPromise} />
      </Suspense>
    </article>
  );
}
What’s happening
  1. Heading returns early when it has no children, and only then reads the context. With use that's allowed. The test renders it under <ThemeContext value="dark"> with "Hi" (<h1 class="dark">Hi</h1>), then with null (renders nothing), then "Back" again, with no errors.
  2. Post renders immediately: the heading "My post" appears right away because it doesn't depend on the comments.
  3. Comments calls use(commentsPromise). The promise is pending, so Comments suspends and the inner boundary shows "Loading comments…".
  4. The promise resolves with [{ id: 1, text: "Nice!" }]. React retries Comments, use returns the array, and "Nice!" renders.
  5. The promise came from the caller. In a Next.js app it's typically started in a Server Component (const commentsPromise = getComments(postId), not awaited) and passed to a Client Component, so the server can stream the page while the comments load. In a client app it comes from a cache or a router loader.

Rules and real messages from React 19.3:

  • Don't create the promise during render. A new promise on every render never settles in time. In my tests the component either stayed on the fallback forever, refetching on every retry (see use() with Suspense for data), or, when React replayed the render with a different promise, logged: "A component was suspended by an uncached promise. Creating promises inside a Client Component or hook is not yet supported, except via a Suspense-compatible library or framework."
  • Don't wrap it in try/catch. React logged "use was called from inside a try/catch block. This is not allowed and can lead to unexpected behavior. To handle errors triggered by use, wrap your component in a error boundary.", and the catch received React's internal "Suspense Exception: This is not a real error!…" object instead of real data. Use an error boundary, or promise.catch(() => fallbackValue) before passing it to use.
  • Rejected promises throw to the nearest error boundary.
  • 19.3 adds a development warning "when use is called incorrectly in a conditional" (react#37104, from the 19.3 changelog). The post doesn't say which pattern triggers it, and I didn't reproduce it.

#Document metadata, stylesheets and preloading

Added

Before React 19, <title> and <meta> were "outside" React: you set document.title in an effect or used react-helmet. React 19 lets any component render <title>, <meta> and <link>, and React hoists them into <head>, on the client and in server rendering alike. It also manages stylesheets (load order via precedence, deduplication, waiting for them before showing content that needs them), async scripts (deduplicated wherever they're rendered), and gives you functions to hint resource loading early.

Mental model: the component that knows what the page is (a blog post, a product) also declares what the <head> should contain, and React does the plumbing, like it does for the DOM in <body>.

BlogPost.jsx
import { preconnect, preload, prefetchDNS } from "react-dom";

export function BlogPost({ post }) {
  preconnect("https://cdn.example.com"); // we'll fetch from here soon
  prefetchDNS("https://analytics.example.com"); // we might
  preload("https://cdn.example.com/fonts/inter.woff2", { as: "font", type: "font/woff2", crossOrigin: "" });

  return (
    <article>
      <title>{`${post.title} | My Blog`}</title>
      <meta name="description" content={post.summary} />
      <link rel="canonical" href={`https://blog.example.com/${post.slug}`} />
      <h1>{post.title}</h1>
      <p>{post.summary}</p>
    </article>
  );
}
What’s happening
  1. The <title>, <meta> and <link rel="canonical"> are written inside <article>, but the rendered body contains only <article><h1>React 19</h1><p>What changed</p></article>. React moved the three tags into <head>, and document.title is "React 19 | My Blog".
  2. The resource functions are called during render. They don't render anything; they add hints to <head>: <link rel="preconnect">, <link rel="dns-prefetch"> and <link rel="preload" as="font" …>. React deduplicates them, so calling preconnect from 20 components adds one tag. The test reads <head> in this order: the three hints, then the title, meta and canonical link.
  3. On unmount, the component's own <title>, <meta> and <link> are removed (document.title back to ""), but the three resource hints stay, since they're hints for the whole document.
  4. <title> children must be a single string, which is why it's written as a template literal. <title>{post.title} | My Blog</title> passes an array of two children; in the sandbox that hoisted an empty <title></title> (document.title was "") with no warning at all.
  5. For the same page rendered on the server, React puts the tags into the streamed <head>, which is what search engines and link previews read.

Stylesheets: give a <link rel="stylesheet"> a precedence and React treats it as a managed resource: it's moved into <head> (the test sees <link rel="stylesheet" href="/card.css" data-precedence="default">), inserted in precedence order, and only inserted once however many components render it. React also waits for it to load before revealing the content that depends on it (inside a Suspense boundary), to avoid a flash of unstyled content. jsdom doesn't load CSS, and in my test the content rendered without waiting, so I couldn't observe that part in the sandbox.

API (from react-dom)AddsUse when
prefetchDNS(href)<link rel="dns-prefetch">You might request something from that host
preconnect(href)<link rel="preconnect">You will request from that host, but don't know the exact file yet
preload(href, { as })<link rel="preload">You know a file you'll need soon (font, image, stylesheet)
preinit(href, { as })A <script> or stylesheet that loads and runs/applies nowYou need it right away
preloadModule / preinitModuleThe ES module versionsModule scripts

When to still use a library: route-level defaults and overrides (react-helmet, or your framework's metadata API such as Next.js's metadata export), which merge titles from nested routes. For a single component declaring its own page's title, React 19 is enough.

#Removed and deprecated APIs

Added

React 19 deleted APIs that had been deprecated for years. Interviews like this topic because codebases still contain the old forms, and an upgrade fails in specific ways. The overall direction: one way to do each thing: JavaScript defaults instead of defaultProps, TypeScript instead of propTypes, createRoot instead of render, ref objects/callbacks instead of strings, createContext instead of legacy context.

Each one, tested on React 19.3:

Removed.test.jsx
// 1. Legacy root APIs are gone from react-dom
ReactDOM.render; // undefined (also hydrate, unmountComponentAtNode, findDOMNode)

// 2. defaultProps on function components is ignored
function Old({ name }) { return <p>Hello {String(name)}</p>; }
Old.defaultProps = { name: "world" }; // renders "Hello undefined"
function New({ name = "world" }) { return <p>Hi {name}</p>; } // renders "Hi world"

// 3. propTypes are silently ignored (no warning for a wrong type)
Price.propTypes = { amount: PropTypes.number.isRequired };
<Price amount="not a number" />; // renders, console stays empty

// 4. String refs throw
class Legacy extends Component {
  render() { return <input ref="name" />; }
}
// Error: Expected ref to be a function, an object returned by React.createRef(), or undefined/null.

// 5. element.ref is deprecated; ref is in props now
const el = <div ref={fn} />;
el.ref; // logs "Accessing element.ref was removed in React 19. ref is now a regular prop. …"
el.props.ref; // fn
What’s happening
  1. ReactDOM.render and friends are simply not exported in 19.3, so old entry files fail with a TypeError: … render is not a function (the exact prefix depends on your bundler). The replacement is createRoot(container).render(<App />) from react-dom/client (and hydrateRoot, root.unmount()). See Rendering an app: createRoot vs ReactDOM.render.
  2. defaultProps on a function component does nothing: the test renders "Hello undefined", with no warning. Default parameters ({ name = "world" }) are the replacement. On class components, static defaultProps still works (the test renders "Hey class world").
  3. propTypes checks no longer run at all: passing a string where PropTypes.number.isRequired is declared produced no console output. The prop-types package still installs and exports validators; React just never calls them. Use TypeScript, or validate at the boundaries with something like Zod.
  4. String refs throw during render with "Expected ref to be a function, an object returned by React.createRef(), or undefined/null." Replace with createRef() in classes or a callback ref. There's a codemod: npx codemod@latest react/19/replace-string-ref.
  5. element.ref logs an error and points you at element.props.ref, since ref is now a regular prop. This mainly affects libraries that clone elements and read their refs.

The rest of the list:

Removed or deprecatedWhat happens in 19.3Replacement
Legacy context (contextTypes, childContextTypes, getChildContext)Context is undefined; logs "%s uses the legacy childContextTypes API which was removed in React 19. Use React.createContext() instead."createContext + static contextType or useContext
ReactDOM.useFormStateStill works but logs "ReactDOM.useFormState has been renamed to React.useActionState."useActionState from react
react-dom/test-utilsOnly act is left, and it logs "ReactDOMTestUtils.act is deprecated in favor of React.act."act from react; React Testing Library
React.createFactoryundefinedJSX
Module pattern factories (a function component returning { render() {} })RemovedPlain function components
react-test-rendererDeprecated, logs a warningReact Testing Library
react-test-renderer/shallowRemovedThe separate react-shallow-renderer package, or don't shallow render
UMD builds (<script src="react.development.js">)No longer publishedAn ESM CDN like esm.sh, or a bundler
Errors in renderNo longer re-thrown; uncaught ones go to window.reportError, caught ones to console.erroronUncaughtError / onCaughtError / onRecoverableError on createRoot (see Root error callbacks in React 19)
New JSX transformRequired; the old React.createElement transform shows an "outdated JSX transform" errorAny modern Babel/TS/Vite setup already uses it
AdvancedTypeScript changes in @types/react 19

useRef() requires an argument (useRef(undefined) is fine) and every ref object is mutable (RefObject replaces MutableRefObject); ReactElement["props"] defaults to unknown instead of any; the global JSX namespace is gone in favour of React.JSX; and ref callbacks can't implicitly return a value. The types codemod (npx types-react-codemod@latest preset-19 ./src) handles most of these.

What does this render in React 19?
React
function Button({ label, size }) {
  return <button className={size}>{label}</button>;
}
Button.defaultProps = { size: "medium" };

<Button label="Save" />;
Show answer

<button>Save</button> with no class: size is undefined, so className={undefined} renders no attribute at all.

What’s happening
  1. React 19 doesn't read defaultProps from function components any more, so the props object is just { label: "Save" }.
  2. size is undefined, and React omits attributes whose value is undefined.
  3. Nothing warns: the assignment to Button.defaultProps is just a property on a function that nobody reads. That's why this bug can slip through an upgrade.
  4. Fix: function Button({ label, size = "medium" }). In React 18.3 you'd have seen a deprecation warning for function defaultProps; that's one reason to upgrade through 18.3.

#React 19.1 to 19.3: what's new

Added

React now ships minor releases with real features rather than waiting for a major. Everything below is from the official release posts on react.dev (19.2 on October 1, 2025; 19.3 on September 9, 2026) and the React CHANGELOG (19.1.0 on March 28, 2025), checked against the installed react@19.3.0 and @types/react@19.3.0. I tested what can run in jsdom; things that need a real browser (View Transition animations, Trusted Types) or a server (partial pre-rendering, Server Components) are described, not run.

React 19.1 (March 2025)

  • Owner Stacks and captureOwnerStack() (development only): a stack of which components rendered the current one, rather than the parent chain. The test calls it inside Leaf and gets at Middle … / at App …. Error overlays and logging tools use it.
  • Suspense improvements: Suspense boundaries behave consistently on the client, the server and during hydration; hydration scheduling and retries were improved.
  • useId format changed so ids are valid in CSS selectors (no more colons).
  • Development warnings for null/undefined values created in effects, and act removed from production builds.

React 19.2 (October 2025)

  • <Activity mode="visible" | "hidden">: hide part of the tree while keeping its state, cleaning up its effects and pre-rendering it at low priority (see Activity: hiding UI without losing state).
  • useEffectEvent: a function called from an effect that always sees the latest props/state and isn't a dependency (see useEffectEvent).
  • cacheSignal() (Server Components only): an AbortSignal that fires when a cache()d result is no longer needed, so you can abort its fetch.
  • Performance Tracks in Chrome DevTools: a Scheduler track (what React works on at each priority) and a Components track.
  • Partial pre-rendering in react-dom: prerender returns a static shell plus postponed state, and resume / resumeAndPrerender (and Node-stream versions) fill in the dynamic parts later.
  • Batched Suspense reveals in streaming SSR, so server-streamed content appears in larger groups (it stops batching as page load nears the 2.5 s LCP threshold), and Web Streams APIs available in Node.
  • eslint-plugin-react-hooks v6: flat config by default, plus React Compiler-powered rules.
  • useId prefix is now _r_ (19.0 used :r:, 19.1 «r»), valid for view-transition-name and XML names. The test's first id is _r_0_, and it works in document.querySelector("#_r_0_"), which the old colon format didn't without escaping.

React 19.3 (September 2026)

  • <ViewTransition> is stable: wrap content to animate it with the browser's View Transition API when it enters, exits, updates or moves between places (share). Only updates inside a Transition (startTransition, useDeferredValue, a Suspense reveal, an Action) animate; urgent updates don't. Animations are set with View Transition classes in CSS (enter, exit, update, share, default props) or the onEnter/onExit/onUpdate/onShare callbacks.
  • addTransitionType(type): call it inside startTransition to say why a transition happened, so <ViewTransition enter={{ next: "from-right", previous: "from-left" }}> can animate differently. jsdom has no document.startViewTransition, so in the test the slide simply changed from "Slide 1" to "Slide 2" without animating. A browser without the API behaves the same way.
  • Fragment refs are stable: <Fragment ref={ref}> gives a FragmentInstance that works on the fragment's children as a group, with no wrapper <div>: focus/focusLast/blur, addEventListener, observeUsing(observer) for Intersection/ResizeObservers, getClientRects, scrollIntoView and more. In the test, ref.current.focus() focused the first focusable child (the "Bold" button, skipping the <span>), and observeUsing observed each first-level child (Tools:, Bold, Italic).
  • browser() from react-dom: use(browser()) makes a component render only in the browser. On the server it suspends, so the nearest Suspense fallback goes into the HTML; on the client it doesn't suspend (the test's component rendered straight away). It replaces the "set a mounted flag in an effect" and typeof window !== "undefined" patterns for things like local time zones or localStorage.
  • Trusted Types: React passes TrustedHTML/TrustedScript/TrustedScriptURL objects through instead of turning them into strings, so sites enforcing require-trusted-types-for 'script' work.
  • <Context> rendered directly in Server Components (imported from a 'use client' module), with no wrapper Provider component.
  • From the changelog: Transitions render independently instead of being entangled into one render; effects are double-invoked in Strict Mode during hydration too; onFullscreenChange/onFullscreenError events; and fixes for useDeferredValue getting stuck, context in Suspense fallbacks, and several <Activity> and <ViewTransition> bugs.
Toolbar.jsx
import { Fragment, useLayoutEffect, useRef } from "react";

function Toolbar() {
  const ref = useRef(null);
  useLayoutEffect(() => {
    const observer = new IntersectionObserver(() => {});
    ref.current.observeUsing(observer); // observe every first-level child
    ref.current.focus(); // focus the first focusable child, depth-first
    return () => ref.current?.unobserveUsing(observer);
  }, []);
  return (
    <Fragment ref={ref}>
      <span>Tools:</span>
      <button>Bold</button>
      <button>Italic</button>
    </Fragment>
  );
}
What’s happening
  1. Before 19.3, a ref on a fragment wasn't possible: to focus or observe a group of siblings you added a wrapper <div> (which can break flex/grid layouts and table markup) or collected a ref per child.
  2. ref.current is a FragmentInstance. In the installed react-dom its prototype has addEventListener, removeEventListener, dispatchEvent, focus, focusLast, blur, observeUsing, unobserveUsing, getClientRects, getRootNode, compareDocumentPosition and scrollIntoView.
  3. observeUsing(observer) calls observer.observe(child) for each first-level DOM child: the test's fake observer saw "Tools:", "Bold", "Italic". A second test adds a child after the effect ran, and React called observe for it too (["one", "two"]), so the observation follows the fragment's children.
  4. focus() searches the children depth-first for the first focusable element. The <span> isn't focusable, so the "Bold" button gets focus.
  5. The cleanup uses unobserveUsing. The 19.3 changelog also mentions a fixed listener leak in FragmentInstance, so stay on the latest patch if you rely on it.

Not covered here because I couldn't confirm it: anything announced for after 19.3 (the React Compiler 1.0, released October 2025, is a separate package and is covered in The React Compiler).

Built from Rohit’s “React Interview concepts” doc. Examples target React 19.3 and are tested with Vitest and React Testing Library.

RohitDownloads · All chapters

Sync progress across devices

Type the same private phrase on your Mac and your phone, and your Learned ticks follow you between them — on every study site.

The phrase never leaves this device: only a fingerprint of it is sent, and the server stores a fingerprint of that. Anyone who knows the phrase could see or change your ticks, so pick something you don’t use elsewhere.

Sync is on in this browser.

Sync ID

This ID must be the same on every device. If another device shows a different one, its phrase is different (capital letters count): tap “Turn off here” on it and type the phrase again exactly.