React 19: Complete Guide to New Features

React 19 is the most significant update to the framework since the introduction of Hooks. These aren’t cosmetic changes: the mental model for how we manage state, forms, and rendering changes in a structural way. This guide covers everything you need to know with real-world examples.

The Problem React 19 Solves

Before diving into the new features, it’s worth understanding the pain points that existed:

  • Forms: handling isPending, errors, optimism, and rollback required dozens of lines of manual state
  • Excessive memoization: useMemo and useCallback were used by default “just in case,” adding noise without measurable benefit
  • Refs and forwardRef: the API was verbose and error-prone in library components

React 19 tackles all three problems directly.

Actions: The New Model for Forms and Mutations

The central concept of React 19 is Actions: functions that handle state transitions asynchronously. The React runtime automatically manages pending state, errors, and synchronization.

useActionState

Replaces the usual useState + isLoading + error pattern:

import { useActionState } from 'react';

async function createUser(prevState: FormState, formData: FormData) {
  try {
    const user = await api.createUser({
      name: formData.get('name') as string,
      email: formData.get('email') as string,
    });
    return { success: true, user, error: null };
  } catch (err) {
    return { success: false, user: null, error: 'Failed to create user' };
  }
}

export function CreateUserForm() {
  const [state, dispatch, isPending] = useActionState(createUser, {
    success: false,
    user: null,
    error: null,
  });

  return (
    <form action={dispatch}>
      <input name="name" placeholder="Name" required />
      <input name="email" type="email" placeholder="Email" required />

      {state.error && <p className="error">{state.error}</p>}
      {state.success && <p className="success">User {state.user?.name} created</p>}

      <button type="submit" disabled={isPending}>
        {isPending ? 'Creating...' : 'Create user'}
      </button>
    </form>
  );
}

useFormStatus

Lets child components read the parent form state without prop drilling:

import { useFormStatus } from 'react-dom';

function SubmitButton({ label }: { label: string }) {
  const { pending } = useFormStatus();

  return (
    <button type="submit" disabled={pending} className={pending ? 'opacity-50' : ''}>
      {pending ? 'Processing...' : label}
    </button>
  );
}

// Use it inside any <form>
function LoginForm() {
  return (
    <form action={loginAction}>
      <input name="email" type="email" />
      <input name="password" type="password" />
      <SubmitButton label="Sign in" />
    </form>
  );
}

useOptimistic

Updates the UI immediately while the request is in flight, and reverts if it fails:

import { useOptimistic } from 'react';

type Message = { id: number; text: string; sending?: boolean };

export function MessageList({ messages }: { messages: Message[] }) {
  const [optimisticMessages, addOptimistic] = useOptimistic(
    messages,
    (state, newMessage: Message) => [...state, { ...newMessage, sending: true }]
  );

  async function sendMessage(formData: FormData) {
    const text = formData.get('message') as string;
    const tempMessage = { id: Date.now(), text, sending: true };

    addOptimistic(tempMessage); // UI updates immediately
    await api.sendMessage(text); // Real request in the background
  }

  return (
    <div>
      {optimisticMessages.map(msg => (
        <div key={msg.id} className={msg.sending ? 'opacity-60' : ''}>
          {msg.text} {msg.sending && '⏳'}
        </div>
      ))}
      <form action={sendMessage}>
        <input name="message" />
        <button type="submit">Send</button>
      </form>
    </div>
  );
}

React Compiler: The End of Manual useMemo

The React Compiler (formerly “React Forget”) statically analyzes your code and applies memoization automatically where it makes sense. You no longer need to reason about when to use useMemo or useCallback.

// Before React Compiler — you had to memoize manually
const ExpensiveComponent = memo(({ items, onSelect }: Props) => {
  const filtered = useMemo(
    () => items.filter(i => i.active),
    [items]
  );
  const handleSelect = useCallback((id: number) => {
    onSelect(id);
  }, [onSelect]);

  return <List items={filtered} onSelect={handleSelect} />;
});

// With React Compiler — write normal code, the compiler handles it
function ExpensiveComponent({ items, onSelect }: Props) {
  const filtered = items.filter(i => i.active); // Compiler memoizes if necessary

  return <List items={filtered} onSelect={onSelect} />;
}

To enable it in a Vite project:

npm install --save-dev babel-plugin-react-compiler
// vite.config.ts
export default defineConfig({
  plugins: [
    react({
      babel: {
        plugins: [['babel-plugin-react-compiler', {}]],
      },
    }),
  ],
});

The use() Hook

Allows reading a Promise or Context directly inside a component, even inside conditional blocks:

import { use, Suspense } from 'react';

// Reading a Promise
function UserProfile({ userPromise }: { userPromise: Promise<User> }) {
  const user = use(userPromise); // Suspends until resolved

  return <div>{user.name}</div>;
}

// Use it with Suspense
function App() {
  const userPromise = fetchUser(userId);

  return (
    <Suspense fallback={<Skeleton />}>
      <UserProfile userPromise={userPromise} />
    </Suspense>
  );
}

// Reading Context conditionally (impossible with useContext)
function ThemeButton({ showLabel }: { showLabel: boolean }) {
  if (showLabel) {
    const theme = use(ThemeContext); // ✅ Inside an if — now valid
    return <button style={{ color: theme.primary }}>Label</button>;
  }
  return <button>No label</button>;
}

Ref Improvements: The End of forwardRef

Refs can now be passed as a prop directly, eliminating the forwardRef boilerplate:

// Before — you needed forwardRef
const Input = forwardRef<HTMLInputElement, InputProps>((props, ref) => (
  <input ref={ref} {...props} />
));

// React 19 — ref is a normal prop
function Input({ ref, ...props }: InputProps & { ref?: React.Ref<HTMLInputElement> }) {
  return <input ref={ref} {...props} />;
}

// Identical usage in both cases
function Form() {
  const inputRef = useRef<HTMLInputElement>(null);
  return <Input ref={inputRef} placeholder="Name" />;
}

Document Metadata from Components

You can manage <title>, <meta> and <link> directly in components without external libraries:

function ArticlePage({ article }: { article: Article }) {
  return (
    <>
      <title>{article.title} — My Blog</title>
      <meta name="description" content={article.excerpt} />
      <meta property="og:title" content={article.title} />
      <link rel="canonical" href={`https://example.com/blog/${article.slug}`} />

      <article>
        <h1>{article.title}</h1>
        <p>{article.content}</p>
      </article>
    </>
  );
}

React automatically hoists these tags to <head>, without duplicates.

Migration Guide from React 18

Most React 18 code works without changes. What you should review:

Old patternReact 19 equivalent
ReactDOM.render()createRoot().render() (already React 18)
forwardRef + useImperativeHandleNormal props with ref
Defensive useMemo / useCallbackRemove them, the Compiler handles it
Form management librariesuseActionState for simple cases

Conclusion

React 19 significantly reduces boilerplate code in the areas where it hurts most: forms, memoization, and component composition. The migration is incremental and backward compatible, making it easy to adopt in existing projects without a massive rewrite.

The biggest change isn’t technical but mental: you can go back to writing straightforward React code without constantly thinking about performance optimizations.