Skip to content

What’s new in React 19

In this post I want to catalog the new important features of React 19.

React 19 is almost ready for its stable release.

A lot of the features that Next.js currently uses are still marked as experimental in React, and available in the Canary release of React.

RSC, server actions and company have been so much central to every discussion on X that I forgot everything about this is not in the stable version of React yet.

Which is not bad per se. I think the ecosystem can move forward way faster, and in a better direction, by having less big releases and more iterative exploration.

So all I’m going to list is already stuff we use in the latest Next.js version, but I wanted somewhere I could refer to all the time to get a tldr; of them.

Server Components

React Server Components (RSC) is definitely the biggest change in React since hooks were introduced in 2018.

It’s a ground-breaking switch from what we used to do.

Basically React with RSC transforms into a full-stack framework, without needing what frameworks used to provide.

We used to need Next.js or Remix (or others) to provide server-side features to React, otherwise we were limited to using client-side rendering (what happens when you use Vite).

But with RSC, server side rendering is now native to React, so frameworks can now focus on improving other parts of the whole “application built with React” experience.

With server components:

Directives

Components in React 19 are now server-rendered by default.

To render a component client-side only, you use a directive at the top of it:

'use client'

Actions (form actions)

Within a component you might need to respond to an event (for example a click) with an event handler that must trigger something on the server.

Very common pattern:

import { useState } from 'react'

export const Demo = () => {
  const [fullName, setFullName] = useState('')

  const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault()

    await fetch('https://api.example.com/submit', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({ fullName }),
    })
  }

  return (
    <div>
      <form onSubmit={handleSubmit}>
        <input
          type='text'
          value={fullName}
          onChange={(e) => setFullName(e.target.value)}
        />
        <button type='submit'>Submit</button>
      </form>
    </div>
  )
}

Same example, using FormData, which removes the need to track each individual input field value using a state management tool (useState in the above example):

import { useRef } from 'react'

export const Demo = () => {
  const formRef = useRef<HTMLFormElement>(null)

  const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault()

    if (formRef.current) {
      const formData = new FormData(formRef.current)

      await fetch('https://api.example.com/submit', {
        method: 'POST',
        body: formData,
      })
    }
  }

  return (
    <div>
      <form ref={formRef} onSubmit={handleSubmit}>
        <input
          type='text'
          name='fullName'
        />
        <button type='submit'>Submit</button>
      </form>
    </div>
  )
}

The same thing now can be performed using an action:

export const Demo = () => {
  async function myFormAction(formData) => {
    await fetch('https://api.example.com/submit', {
      method: 'POST',
      body: formData,
    })
  }

  return (
    <div>
      <form action={myFormAction}>
        <input
          type='text'
          name='fullName'
        />
        <button type='submit'>Submit</button>
      </form>
    </div>
  )
}

Look how much simpler the code is, because now we don’t track the individual input fields state, but also we don’t respond to a browser event directly, and we don’t have to pass the form ref around to keep track of how to access it, because the actions is directly passed the FormData object.

Server actions

The above action was executed client-side.

Server actions are defined in a separate .ts file marked with the 'use server' directive.

This tells React what is in that file can only run on the server, and can be called from a client component:

//actions.ts

'use server'

async function myServerAction(formData) => {
  //we are on the server, we can directly
  //do something with the form data
}

In a client component:

'use client'

import { myServerAction } from './actions'

export const Demo = () => {
  return (
    <div>
      <form action={myServerAction}>
        <input
          type='text'
          name='fullName'
        />
        <button type='submit'>Submit</button>
      </form>
    </div>
  )
}

The useActionState hook

(in previous canary versions known as useFormState)

Client components can use this new hook to “peek” into the state of a server action.

We basically pass it the server action, and an initial state object.

What we get back is the state object, the form action we attach to the form, and the pending object:

"use client"

import { useActionState } from "react"

import { myServerAction } from './actions'

const initialState = {
  message: "",
}

export const Demo = () => {
  const [state, formAction, pending] = 
    useActionState(myServerAction, initialState)

  return (
    <div>
      <form action={formAction}>
        <input
          type='text'
          name='fullName'
        />
        {state?.message && <p>{state.message}</p>}
        <button 
          aria-disabled={pending} 
          type='submit'>
          {pending ? "Submitting..." : "Submit"}
        </button>
      </form>
    </div>
  )
}

Basically we have a very easy way of handing feedback from the server action (in this case we assume it returns an object with a message string), and we have an easy way of handling the pending state, so we can disable the button while the action is running.

Stuff we didn’t implement before, but you can see how it’s quick to implement using useActionState.

The useFormStatus hook

Similarly to how we can get an action’s pending state through useActionState, we can use useFormStatus to get a form action’s pending state from a component that’s included in a form.

For example a submit button component. It’s common to be its own component, shared across different forms across the app, so this hook makes it easy to access its containing form’s pending state:

"use client"

import { useActionState } from "react"

import { myServerAction } from './actions'

const initialState = {
  message: "",
}

export const Demo = () => {
  const [state, formAction, pending] = 
    useActionState(myServerAction, initialState)

  return (
    <div>
      <form action={formAction}>
        <input
          type='text'
          name='fullName'
        />
        {state?.message && <p>{state.message}</p>}
        <SubmitButton />
      </form>
    </div>
  )
}

const SubmitButton = () => {
  const { pending } = useFormStatus()
  
  return (
	  <button 
      aria-disabled={pending} 
      type='submit'>
      {pending ? "Submitting..." : "Submit"}
    </button>
  )
}

The useOptimistic hook

This hook lets you update the UI immediately in response to an action, before the server responds.

You pass to it the current state you want to manage through it (for example, an array messages), and a function to update the optimistic state.

It returns you the optimistic state (which you use for immediate rendering), and a function to update it.

You call the hook before the server request.

After the server response is received, you update the actual state (in the example, messages, and in the TSX you render the optimistic state, which after you update with the actual state, renders the actual state.

You can use this hook in a client component.

Here’s a simple todo app example.

Define an addTodo server action:

'use server'

type Todo = {
  todo: string
}

export async function addTodo(newTodo: string): Promise<Todo> {
  // Simulating server delay
  await new Promise((resolve) => setTimeout(resolve, 3000))
  // Add todo on server
  return {
    todo: newTodo + ' test',
  }
}

In this server action, after 3 seconds we return the todo sent, plus ' test' to understand that this is the returned value from the server action.

Client-side we use the useOptimistic hook to generate an optimisticTodos array, which we use to list todos in the TSX:


const [optimisticTodos, addOptimisticTodo] = useOptimistic<Todo[], string>(
  todos,
  (state, newTodo) => [...state, { todo: newTodo }]
)

When we hit the form action (the user presses the submit button), the addOptimisticTodo() function is called to add the new todo.

Then we hit the server, which takes some time as the server action waits 3 seconds).

Finally when we’re back we call setTodos() to update the todos list with the actual state coming from the server.

Full code:

'use client'

import { useOptimistic, useState, useRef } from 'react'
import { addTodo } from './actions'

type Todo = {
  todo: string
}

export default function Todos() {
  const [todos, setTodos] = useState<Todo[]>([])
  const formRef = useRef<HTMLFormElement>(null)

  const [optimisticTodos, addOptimisticTodo] = useOptimistic<Todo[], string>(
    todos,
    (state, newTodo) => [...state, { todo: newTodo }]
  )

  const formAction = async (formData: FormData) => {
    const todo = formData.get('todo') as string
    addOptimisticTodo(todo)
    formRef.current?.reset()

    try {
      const result = await addTodo(todo)

      // Update the actual state with the server response
      setTodos((prevTodos) => [...prevTodos, { todo: result.todo }])
    } catch (error) {
      console.error('Error adding todo:', error)
      // Optionally, you could remove the optimistic update here if the server request fails
    }
  }

  return (
    <div>
      {optimisticTodos.map((m, i) => (
        <div key={i}>{m.todo}</div>
      ))}
      <form action={formAction} ref={formRef}>
        <input type='text' name='todo' />
        <button type='submit'>Send</button>
      </form>
    </div>
  )
}

The use hook

When you use Suspense, you can use the use() hook, and pass it a promise (or other values) and React will suspend that component until the promise resolves:

<Suspense fallback={<Spinner />}>
  <Profile userId={123} />
</Suspense>
import { use } from 'react'

async function fetchUser() {
  //....
}

export function Profile({ userId }) {
  const user = use(fetchUser(userId));
  return <h1>{user.name}</h1>;
}

This is a “special” hook because hooks normally must be called at the top of a component, but use() does not have this limitation.

This new function simplifies creating smooth data loading experiences.

More

There are more features I would consider minor, and the above are the most important / impactful.

Soon I will update my React Handbook and my Next.js Handbook to cover the latest versions of both.

Make sure you join my newsletter to be notified when I release them.

→ Get my React Beginner's Handbook

I wrote 17 books to help you become a better developer, download them all at $0 cost by joining my newsletter

JOIN MY CODING BOOTCAMP, an amazing cohort course that will be a huge step up in your coding career - covering React, Next.js - next edition February 2025

Bootcamp 2025

Join the waiting list