diff --git a/.changeset/gentle-windows-enjoy.md b/.changeset/gentle-windows-enjoy.md
new file mode 100644
index 000000000000..ac9b1ccd92e9
--- /dev/null
+++ b/.changeset/gentle-windows-enjoy.md
@@ -0,0 +1,46 @@
+---
+"@astrojs/react": minor
+"astro": minor
+---
+
+Add support for [the React 19 `useActionState()` hook](https://react.dev/reference/react/useActionState) when using Astro Actions. This introduces progressive enhancement when calling an Action with the `withState()` utility.
+
+This example calls a `like` action that accepts a `postId` and returns the number of likes. Pass this action to the `experimental_withState()` function to apply progressive enhancement info, and apply to `useActionState()` to track the result:
+
+```tsx
+import { actions } from 'astro:actions';
+import { experimental_withState } from '@astrojs/react/actions';
+
+export function Like({ postId }: { postId: string }) {
+ const [state, action, pending] = useActionState(
+ experimental_withState(actions.like),
+ 0, // initial likes
+ );
+
+ return (
+
+ );
+}
+```
+
+You can also access the state stored by `useActionState()` from your action `handler`. Call `experimental_getActionState()` with the API context, and optionally apply a type to the result:
+
+```ts
+import { defineAction, z } from 'astro:actions';
+import { experimental_getActionState } from '@astrojs/react/actions';
+
+export const server = {
+ like: defineAction({
+ input: z.object({
+ postId: z.string(),
+ }),
+ handler: async ({ postId }, ctx) => {
+ const currentLikes = experimental_getActionState(ctx);
+ // write to database
+ return currentLikes + 1;
+ }
+ })
+}
diff --git a/packages/astro/e2e/actions-react-19.test.js b/packages/astro/e2e/actions-react-19.test.js
index bfb57d8f9096..5ce72a419acb 100644
--- a/packages/astro/e2e/actions-react-19.test.js
+++ b/packages/astro/e2e/actions-react-19.test.js
@@ -9,6 +9,11 @@ test.beforeAll(async ({ astro }) => {
devServer = await astro.startDevServer();
});
+test.afterEach(async ({ astro }) => {
+ // Force database reset between tests
+ await astro.editFile('./db/seed.ts', (original) => original);
+});
+
test.afterAll(async () => {
await devServer.stop();
});
@@ -21,6 +26,7 @@ test.describe('Astro Actions - React 19', () => {
await expect(likeButton).toBeVisible();
await likeButton.click();
await expect(likeButton, 'like button should be disabled when pending').toBeDisabled();
+ await expect(likeButton).not.toBeDisabled({ timeout: 5000 });
});
test('Like action - server progressive enhancement', async ({ page, astro }) => {
@@ -30,7 +36,26 @@ test.describe('Astro Actions - React 19', () => {
await expect(likeButton, 'like button starts with 10 likes').toContainText('10');
await likeButton.click();
- // May contain "12" after the client button test.
- await expect(likeButton, 'like button increments').toContainText(/11|12/);
+ await expect(likeButton, 'like button increments').toContainText('11');
+ });
+
+ test('Like action - client useActionState', async ({ page, astro }) => {
+ await page.goto(astro.resolveUrl('/blog/first-post/'));
+
+ const likeButton = page.getByLabel('likes-action-client');
+ await expect(likeButton).toBeVisible();
+ await likeButton.click();
+
+ await expect(likeButton, 'like button increments').toContainText('11');
+ });
+
+ test('Like action - server useActionState progressive enhancement', async ({ page, astro }) => {
+ await page.goto(astro.resolveUrl('/blog/first-post/'));
+
+ const likeButton = page.getByLabel('likes-action-server');
+ await expect(likeButton, 'like button starts with 10 likes').toContainText('10');
+ await likeButton.click();
+
+ await expect(likeButton, 'like button increments').toContainText('11');
});
});
diff --git a/packages/astro/e2e/fixtures/actions-blog/src/actions/index.ts b/packages/astro/e2e/fixtures/actions-blog/src/actions/index.ts
index 036cc087fdca..0588f626c8a8 100644
--- a/packages/astro/e2e/fixtures/actions-blog/src/actions/index.ts
+++ b/packages/astro/e2e/fixtures/actions-blog/src/actions/index.ts
@@ -7,7 +7,7 @@ export const server = {
like: defineAction({
input: z.object({ postId: z.string() }),
handler: async ({ postId }) => {
- await new Promise((r) => setTimeout(r, 200));
+ await new Promise((r) => setTimeout(r, 1000));
const { likes } = await db
.update(Likes)
diff --git a/packages/astro/e2e/fixtures/actions-react-19/src/actions/index.ts b/packages/astro/e2e/fixtures/actions-react-19/src/actions/index.ts
index 70ecd04aa2cf..9cb867603add 100644
--- a/packages/astro/e2e/fixtures/actions-react-19/src/actions/index.ts
+++ b/packages/astro/e2e/fixtures/actions-react-19/src/actions/index.ts
@@ -1,5 +1,6 @@
import { db, Likes, eq, sql } from 'astro:db';
-import { defineAction, z } from 'astro:actions';
+import { defineAction, getApiContext, z } from 'astro:actions';
+import { experimental_getActionState } from '@astrojs/react/actions';
export const server = {
blog: {
@@ -21,5 +22,26 @@ export const server = {
return likes;
},
}),
+ likeWithActionState: defineAction({
+ accept: 'form',
+ input: z.object({ postId: z.string() }),
+ handler: async ({ postId }) => {
+ await new Promise((r) => setTimeout(r, 200));
+
+ const context = getApiContext();
+ const state = await experimental_getActionState(context);
+
+ const { likes } = await db
+ .update(Likes)
+ .set({
+ likes: state + 1,
+ })
+ .where(eq(Likes.postId, postId))
+ .returning()
+ .get();
+
+ return likes;
+ },
+ }),
},
};
diff --git a/packages/astro/e2e/fixtures/actions-react-19/src/components/Like.tsx b/packages/astro/e2e/fixtures/actions-react-19/src/components/Like.tsx
index 3928e9eda4a8..0d2dde00916e 100644
--- a/packages/astro/e2e/fixtures/actions-react-19/src/components/Like.tsx
+++ b/packages/astro/e2e/fixtures/actions-react-19/src/components/Like.tsx
@@ -1,5 +1,7 @@
import { actions } from 'astro:actions';
+import { useActionState } from 'react';
import { useFormStatus } from 'react-dom';
+import { experimental_withState } from '@astrojs/react/actions';
export function Like({ postId, label, likes }: { postId: string; label: string; likes: number }) {
return (
@@ -10,6 +12,21 @@ export function Like({ postId, label, likes }: { postId: string; label: string;
);
}
+
+export function LikeWithActionState({ postId, label, likes: initial }: { postId: string; label: string; likes: number }) {
+ const [likes, action] = useActionState(
+ experimental_withState(actions.blog.likeWithActionState),
+ 10,
+ );
+
+ return (
+
+ );
+}
+
function Button({likes, label}: {likes: number; label: string}) {
const { pending } = useFormStatus();
diff --git a/packages/astro/e2e/fixtures/actions-react-19/src/pages/blog/[...slug].astro b/packages/astro/e2e/fixtures/actions-react-19/src/pages/blog/[...slug].astro
index f8c56dea3b6e..f89badfcb05a 100644
--- a/packages/astro/e2e/fixtures/actions-react-19/src/pages/blog/[...slug].astro
+++ b/packages/astro/e2e/fixtures/actions-react-19/src/pages/blog/[...slug].astro
@@ -2,7 +2,7 @@
import { type CollectionEntry, getCollection, getEntry } from 'astro:content';
import BlogPost from '../../layouts/BlogPost.astro';
import { db, eq, Likes } from 'astro:db';
-import { Like } from '../../components/Like';
+import { Like, LikeWithActionState } from '../../components/Like';
export const prerender = false;
@@ -23,6 +23,7 @@ const likesRes = await db.select().from(Likes).where(eq(Likes.postId, post.id)).
---
+ Like
{
likesRes && (
@@ -30,6 +31,10 @@ const likesRes = await db.select().from(Likes).where(eq(Likes.postId, post.id)).
)
}
+ Like with action state
+
+
+
diff --git a/packages/astro/src/@types/astro.ts b/packages/astro/src/@types/astro.ts
index bad3b3cb79c3..d5219d52645e 100644
--- a/packages/astro/src/@types/astro.ts
+++ b/packages/astro/src/@types/astro.ts
@@ -3150,6 +3150,8 @@ export interface SSRResult {
): AstroGlobal;
resolve: (s: string) => Promise;
response: AstroGlobal['response'];
+ request: AstroGlobal['request'];
+ actionResult?: ReturnType;
renderers: SSRLoadedRenderer[];
/**
* Map of directive name (e.g. `load`) to the directive script code
diff --git a/packages/astro/src/actions/runtime/middleware.ts b/packages/astro/src/actions/runtime/middleware.ts
index fced367aecce..8fe0def112c0 100644
--- a/packages/astro/src/actions/runtime/middleware.ts
+++ b/packages/astro/src/actions/runtime/middleware.ts
@@ -7,6 +7,7 @@ import { callSafely } from './virtual/shared.js';
export type Locals = {
_actionsInternal: {
getActionResult: APIContext['getActionResult'];
+ actionResult?: ReturnType;
};
};
@@ -45,6 +46,7 @@ export const onRequest = defineMiddleware(async (context, next) => {
// Cast to `any` to satisfy `getActionResult()` type.
return result as any;
},
+ actionResult: result,
};
Object.defineProperty(locals, '_actionsInternal', { writable: false, value: actionsInternal });
return next();
diff --git a/packages/astro/src/actions/utils.ts b/packages/astro/src/actions/utils.ts
index 0552b34356fd..833cd6dace41 100644
--- a/packages/astro/src/actions/utils.ts
+++ b/packages/astro/src/actions/utils.ts
@@ -2,7 +2,7 @@ import type { APIContext } from '../@types/astro.js';
import { AstroError } from '../core/errors/errors.js';
import type { Locals } from './runtime/middleware.js';
-function hasActionsInternal(locals: APIContext['locals']): locals is Locals {
+export function hasActionsInternal(locals: APIContext['locals']): locals is Locals {
return '_actionsInternal' in locals;
}
diff --git a/packages/astro/src/core/render-context.ts b/packages/astro/src/core/render-context.ts
index 5f35c5f11309..8febc954d4a5 100644
--- a/packages/astro/src/core/render-context.ts
+++ b/packages/astro/src/core/render-context.ts
@@ -4,13 +4,12 @@ import type {
AstroGlobalPartial,
ComponentInstance,
MiddlewareHandler,
- MiddlewareNext,
RewritePayload,
RouteData,
SSRResult,
} from '../@types/astro.js';
import type { ActionAPIContext } from '../actions/runtime/store.js';
-import { createGetActionResult } from '../actions/utils.js';
+import { createGetActionResult, hasActionsInternal } from '../actions/utils.js';
import {
computeCurrentLocale,
computePreferredLocale,
@@ -295,6 +294,10 @@ export class RenderContext {
},
} satisfies AstroGlobal['response'];
+ const actionResult = hasActionsInternal(this.locals)
+ ? this.locals._actionsInternal?.actionResult
+ : undefined;
+
// Create the result object that will be passed into the renderPage function.
// This object starts here as an empty shell (not yet the result) but then
// calling the render() function will populate the object with scripts, styles, etc.
@@ -314,8 +317,10 @@ export class RenderContext {
renderers,
resolve,
response,
+ request: this.request,
scripts,
styles,
+ actionResult,
_metadata: {
hasHydrationScript: false,
rendererSpecificHydrationScripts: new Set(),
diff --git a/packages/astro/templates/actions.mjs b/packages/astro/templates/actions.mjs
index 9c921fa4a7e8..2e3ba6a448d5 100644
--- a/packages/astro/templates/actions.mjs
+++ b/packages/astro/templates/actions.mjs
@@ -12,6 +12,8 @@ function toActionProxy(actionCallback = {}, aggregatedPath = '/_actions/') {
action.safe = (input) => {
return callSafely(() => action(input));
};
+ action.safe.toString = () => path;
+
// Add progressive enhancement info for React.
action.$$FORM_ACTION = function () {
const data = new FormData();
@@ -22,6 +24,16 @@ function toActionProxy(actionCallback = {}, aggregatedPath = '/_actions/') {
data,
}
};
+ action.safe.$$FORM_ACTION = function () {
+ const data = new FormData();
+ data.set('_astroAction', action.toString());
+ data.set('_astroActionSafe', 'true');
+ return {
+ method: 'POST',
+ name: action.toString(),
+ data,
+ }
+ }
// recurse to construct queries for nested object paths
// ex. actions.user.admins.auth()
return toActionProxy(action, path + '.');
diff --git a/packages/integrations/react/client.js b/packages/integrations/react/client.js
index 76f9ead0080d..13515df407c0 100644
--- a/packages/integrations/react/client.js
+++ b/packages/integrations/react/client.js
@@ -67,8 +67,19 @@ const getOrCreateRoot = (element, creator) => {
export default (element) =>
(Component, props, { default: children, ...slotted }, { client }) => {
if (!element.hasAttribute('ssr')) return;
+
+ const actionKey = element.getAttribute('data-action-key');
+ const actionName = element.getAttribute('data-action-name');
+ const stringifiedActionResult = element.getAttribute('data-action-result');
+
+ const formState =
+ actionKey && actionName && stringifiedActionResult
+ ? [JSON.parse(stringifiedActionResult), actionKey, actionName]
+ : undefined;
+
const renderOptions = {
identifierPrefix: element.getAttribute('prefix'),
+ formState,
};
for (const [key, value] of Object.entries(slotted)) {
props[key] = createElement(StaticHtml, { value, name: key });
diff --git a/packages/integrations/react/package.json b/packages/integrations/react/package.json
index 7e08f03c06f1..373e8f848987 100644
--- a/packages/integrations/react/package.json
+++ b/packages/integrations/react/package.json
@@ -21,6 +21,7 @@
"homepage": "https://docs.astro.build/en/guides/integrations-guide/react/",
"exports": {
".": "./dist/index.js",
+ "./actions": "./dist/actions.js",
"./client.js": "./client.js",
"./client-v17.js": "./client-v17.js",
"./server.js": "./server.js",
diff --git a/packages/integrations/react/server.js b/packages/integrations/react/server.js
index 2ff3f55fb25b..a0b2db744ed6 100644
--- a/packages/integrations/react/server.js
+++ b/packages/integrations/react/server.js
@@ -1,6 +1,7 @@
import opts from 'astro:react:opts';
import React from 'react';
import ReactDOM from 'react-dom/server';
+import { AstroError } from 'astro/errors';
import { incrementId } from './context.js';
import StaticHtml from './static-html.js';
@@ -101,9 +102,16 @@ async function renderToStaticMarkup(Component, props, { default: children, ...sl
value: newChildren,
});
}
+ const formState = this ? await getFormState(this) : undefined;
+ if (formState) {
+ attrs['data-action-result'] = JSON.stringify(formState[0]);
+ attrs['data-action-key'] = formState[1];
+ attrs['data-action-name'] = formState[2];
+ }
const vnode = React.createElement(Component, newProps);
const renderOptions = {
identifierPrefix: prefix,
+ formState,
};
let html;
if ('renderToReadableStream' in ReactDOM) {
@@ -114,6 +122,43 @@ async function renderToStaticMarkup(Component, props, { default: children, ...sl
return { html, attrs };
}
+/**
+ * @returns {Promise<[actionResult: any, actionKey: string, actionName: string] | undefined>}
+ */
+async function getFormState({ result }) {
+ const { request, actionResult } = result;
+
+ if (!actionResult) return undefined;
+ if (!isFormRequest(request.headers.get('content-type'))) return undefined;
+
+ const formData = await request.clone().formData();
+ /**
+ * The key generated by React to identify each `useActionState()` call.
+ * @example "k511f74df5a35d32e7cf266450d85cb6c"
+ */
+ const actionKey = formData.get('$ACTION_KEY')?.toString();
+ /**
+ * The action name returned by an action's `toString()` property.
+ * This matches the endpoint path.
+ * @example "/_actions/blog.like"
+ */
+ const actionName = formData.get('_astroAction')?.toString();
+
+ if (!actionKey || !actionName) return undefined;
+
+ const isUsingSafe = formData.has('_astroActionSafe');
+ if (!isUsingSafe && actionResult.error) {
+ throw new AstroError(
+ `Unhandled error calling action ${actionName.replace(/^\/_actions\//, '')}:\n[${
+ actionResult.error.code
+ }] ${actionResult.error.message}`,
+ 'use `.safe()` to handle from your React component.'
+ );
+ }
+
+ return [isUsingSafe ? actionResult : actionResult.data, actionKey, actionName];
+}
+
async function renderToPipeableStreamAsync(vnode, options) {
const Writable = await getNodeWritable();
let html = '';
@@ -170,6 +215,16 @@ async function renderToReadableStreamAsync(vnode, options) {
return await readResult(await ReactDOM.renderToReadableStream(vnode, options));
}
+const formContentTypes = ['application/x-www-form-urlencoded', 'multipart/form-data'];
+
+function isFormRequest(contentType) {
+ // Split off parameters like charset or boundary
+ // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Type#content-type_in_html_forms
+ const type = contentType?.split(';')[0].toLowerCase();
+
+ return formContentTypes.some((t) => type === t);
+}
+
export default {
check,
renderToStaticMarkup,
diff --git a/packages/integrations/react/src/actions.ts b/packages/integrations/react/src/actions.ts
new file mode 100644
index 000000000000..336d322209ab
--- /dev/null
+++ b/packages/integrations/react/src/actions.ts
@@ -0,0 +1,101 @@
+import { AstroError } from 'astro/errors';
+
+type FormFn = (formData: FormData) => Promise;
+
+/**
+ * Use an Astro Action with React `useActionState()`.
+ * This function matches your action to the expected types,
+ * and preserves metadata for progressive enhancement.
+ * To read state from your action handler, use {@linkcode experimental_getActionState}.
+ */
+export function experimental_withState(action: FormFn) {
+ // React expects two positional arguments when using `useActionState()`:
+ // 1. The initial state value.
+ // 2. The form data object.
+
+ // Map this first argument to a hidden input
+ // for retrieval from `getActionState()`.
+ const callback = async function (state: T, formData: FormData) {
+ formData.set('_astroActionState', JSON.stringify(state));
+ return action(formData);
+ };
+ if (!('$$FORM_ACTION' in action)) return callback;
+
+ // Re-bind progressive enhancement info for React.
+ callback.$$FORM_ACTION = action.$$FORM_ACTION;
+ // Called by React when form state is passed from the server.
+ // If the action names match, React returns this state from `useActionState()`.
+ callback.$$IS_SIGNATURE_EQUAL = (actionName: string) => {
+ return action.toString() === actionName;
+ };
+
+ // React calls `.bind()` internally to pass the initial state value.
+ // Calling `.bind()` seems to remove our `$$FORM_ACTION` metadata,
+ // so we need to define our *own* `.bind()` method to preserve that metadata.
+ Object.defineProperty(callback, 'bind', {
+ value: (...args: Parameters) =>
+ injectStateIntoFormActionData(callback, ...args),
+ });
+ return callback;
+}
+
+/**
+ * Retrieve the state object from your action handler when using `useActionState()`.
+ * To ensure this state is retrievable, use the {@linkcode experimental_withState} helper.
+ */
+export async function experimental_getActionState({
+ request,
+}: { request: Request }): Promise {
+ const contentType = request.headers.get('Content-Type');
+ if (!contentType || !isFormRequest(contentType)) {
+ throw new AstroError(
+ '`getActionState()` must be called with a form request.',
+ "Ensure your action uses the `accept: 'form'` option."
+ );
+ }
+ const formData = await request.clone().formData();
+ const state = formData.get('_astroActionState')?.toString();
+ if (!state) {
+ throw new AstroError(
+ '`getActionState()` could not find a state object.',
+ 'Ensure your action was passed to `useActionState()` with the `experimental_withState()` wrapper.'
+ );
+ }
+ return JSON.parse(state) as T;
+}
+
+const formContentTypes = ['application/x-www-form-urlencoded', 'multipart/form-data'];
+
+function isFormRequest(contentType: string) {
+ // Split off parameters like charset or boundary
+ // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Type#content-type_in_html_forms
+ const type = contentType.split(';')[0].toLowerCase();
+
+ return formContentTypes.some((t) => type === t);
+}
+
+/**
+ * Override the default `.bind()` method to:
+ * 1. Inject the form state into the form data for progressive enhancement.
+ * 2. Preserve the `$$FORM_ACTION` metadata.
+ */
+function injectStateIntoFormActionData(
+ fn: (...args: R) => unknown,
+ ...args: R
+) {
+ const boundFn = Function.prototype.bind.call(fn, ...args);
+ Object.assign(boundFn, fn);
+ const [, state] = args;
+
+ if ('$$FORM_ACTION' in fn && typeof fn.$$FORM_ACTION === 'function') {
+ const metadata = fn.$$FORM_ACTION();
+ boundFn.$$FORM_ACTION = () => {
+ const data = (metadata.data as FormData) ?? new FormData();
+ data.set('_astroActionState', JSON.stringify(state));
+ metadata.data = data;
+
+ return metadata;
+ };
+ }
+ return boundFn;
+}