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.