In a previous article, we explored how to fetch and resolve data within a client component. Now, we’ll take it a step further and focus on how to mutate data directly from a client component, using server actions in Next.js.

What are server actions?

At its core, a server action is simply an asynchronous function that runs on the server. It has direct access to secrets, database operations and services. You can find a deeper breakdown here.

// ./actions/createComment.ts

"use server";
export async function createComment(formData) {
    // validate data (Zod)
    // process data (ORM, service)
    // return data or error message
}

Using the server action in a client component

A server action can be invoked in a client component either via the action attribute on a form or directly within a handler method.

In this example, we have a component that includes a form that submits its data to a createComment action. 

// ./components/Comments.tsx

"use client";
// only works with React 19

import { useActionState } from 'react'; 
import { createComment } from './actions/createComment';
export default function Comments() {
    
  const [createdComment, createCommentAction, isCreatingComment] = useActionState(createComment, null);
  return (
    <div>
      <form action={createCommentAction}>
        <div>
          <label htmlFor="message">Message:</label>
          <textarea
            id="message"
            name="message"
            required
          />
        </div>
        <button type="submit" disabled={isCreatingComment}>
          {isCreatingComment ? 'Submitting...' : 'Submit'}
        </button>
      </form>
      
      {createdComment && <p>{createdComment.message}</p>}
    </div>
  );
}

The action’s return state and lifecycle are managed using useActionState, a hook further refined in the upcoming React 19 release. Previously known as useFormState, this hook lacked the isPending return parameter. The parameter has since been added for better state tracking during form submissions.

When using this hook, we need to make sure the server action accepts previousState as its first argument. In this example, we’re setting it to null in the client component (2nd argument on useActionState).

'use server';
// previousState is new here
export async function createComment(previousState, formData) {
    // validate data (Zod)
    // process data (ORM, service)
    // return data or error message
}

We can integrate this with the previous approach to create a client component that both reads and writes data to the server-side database, all without the need for additional API endpoints.

// ./components/Comments.tsx

"use client";
import { use, useActionState } from 'react'; 
import { createComment } from './actions/createComment'; 
export default function Comments({ getComments }) {
  const comments = use(getComments);
  const [createdComment, createCommentAction, isCreatingComment] = useActionState(createComment, null);
  return (
    <div>
      <h2>Existing Comments</h2>
      <ul>
        {comments.map((comment) => (
          <li key={comment.id}>{comment.message}</li>
        ))}
        {createdComment && <li>{createdComment.message}</li>}
      </ul>
      <h2>Add a new comment</h2>
      <form action={async (formData) => {
          await createCommentAction(formData);
          router.refresh();
        }}>
        <div>
          <label htmlFor="message">Message:</label>
          <textarea
            id="message"
            name="message"
            required
          />
        </div>
        <button type="submit" disabled={isCreatingComment}>
          {isCreatingComment ? 'Submitting...' : 'Submit'}
        </button>
      </form>
    </div>
  );
}

By leveraging server actions and React’s evolving features, such as useActionState and the use()hook, we can efficiently manage both data fetching and mutations directly within client components. This approach simplifies the architecture by eliminating the need for custom API endpoints while maintaining a clean and intuitive workflow. As React continues to evolve, these tools provide a powerful way to handle complex interactions, improve performance, and streamline server-client data management, all while keeping the developer experience front and centre.

Overall, crossing boundaries between client and server is becoming more seamless, enabling developers to build dynamic applications that offer fast, responsive user experiences.