useActionState: Invoke server action

React 19 introduces a new hook called useActionState.

useActionState allows invoking asynchronous actions from client components, and it manages the pending state of the action.

We refactor Posts to add a SinglePost component for each post:

export default function Posts({
  posts,
}: {
  posts: readonly (typeof Post.Encoded)[];
}) {
  return (
    <div>
      {posts.map((post) => (
        <SinglePost key={post.id} post={post} />
      ))}
    </div>
  );
}

const SinglePost = ({ post }: { post: typeof Post.Encoded }) => {
  return (
    <div>
      <h1>{post.title}</h1>
      <p>{post.body}</p>
    </div>
  );
};

Inside SinglePost we then use useActionState to invoke the likePost action. The second argument is the action to execute (setLiked):

const SinglePost = ({ post }: { post: typeof Post.Encoded }) => {
  const [_, setLiked] = useActionState(() => likePost(post.id), null);
  return (
    <div>
      <h1>{post.title}</h1>
      <p>{post.body}</p>
      <button onClick={setLiked}>Like</button>
    </div>
  );
};

useActionState provides in its third argument the current state of the request as a boolean. If true, the action is currently pending:

const SinglePost = ({ post }: { post: typeof Post.Encoded }) => {
  const [_, setLiked, pending] = useActionState(() => likePost(post.id), null);
  return (
    <div>
      <h1>{post.title}</h1>
      <p>{post.body}</p>
      <button disabled={pending} onClick={setLiked}>
        Like
      </button>
    </div>
  );
};

When you click the button, the likePost action is executed, and the pending state is updated to true. When the action is finished, the pending state is updated to false.

What about the first argument of useActionState?

useActionState stores the response of executing the action inside the first argument.

The initial/default value of the action state is defined after the action. In this example, the initial value is null.

const [state, setLiked, pending] = useActionState(() => likePost(post.id), null);

This action state can be used to store and use the response of the action. A common use case is storing possible errors when executing the action.

However, this is where the server actions model breaks 🤔

Server actions don't account for possible errors like Effect does. Therefore, we are required to fallback to try/catch, losing all the benefits of typed errors.

const SinglePost = ({ post }: { post: typeof Post.Encoded }) => {
  //     👇 `error` is `string | null`
  const [error, setLiked, pending] = useActionState(async () => {
    try {
      await likePost(post.id);
      return null; // 👈 No error, reset to `null`
    } catch (error) {
      // 👇 Convert error to string
      return error instanceof Error ? error.message : "Unknown error";
    }
  },
    null // 👈 Initial value `null` (no error)
  );
  return (
    <div>
      <h1>{post.title}</h1>
      <p>{post.body}</p>
      <button disabled={pending} onClick={setLiked}>
        Like
      </button>
      
      {/* 👇 Display error if any */}
      {error && <p>{error}</p>}
    </div>
  );
};

That's why I personally don't use server actions at all. Instead, you can model the same behavior using @effect/rpc.

Server actions use the RPC model behind the scenes: a single POST endpoint is created that handles all the actions.

A request to a server action automatically handles all the details (serialization, returning errors, choosing the right action to execute, etc.).

@effect/rpc allows to do the same as server actions, but it includes all the benefits of typed responses provided by Effect.

We will not use @effect/rpc in this example, since it requires setting up a server endpoint, which is out of scope for Effect and React 19.