withastro/astro · error · AstroError

`getActionState()` must be called with a form request.

Error message

`getActionState()` must be called with a form request.

What it means

React's useActionState submits the action as a form post, and getActionState() reads the state object out of that form body. Before parsing, it checks the request Content-Type and requires application/x-www-form-urlencoded or multipart/form-data; anything else (usually a JSON submission, i.e. an action without accept: 'form') throws.

Solutions

  1. Define the action with accept: 'form' so submissions arrive as form data
  2. Submit through a real <form> / useActionState so the browser posts urlencoded or multipart data
  3. If the action must stay JSON, do not call getActionState; carry state through the action input instead

Example fix

// before
const action = defineAction({ handler: async ({ request }) => { const s = await getActionState({ request }); return s; } });
// after
const action = defineAction({ accept: 'form', handler: async ({ request }) => { const s = await getActionState({ request }); return s; } });
Defensive patterns

Strategy: validation

Validate before calling

const ct = request.headers.get('Content-Type') ?? '';
const isForm = ct.startsWith('application/x-www-form-urlencoded') || ct.startsWith('multipart/form-data');
if (!isForm) throw new Response('Expected a form submission', { status: 415 });
const state = await getActionState({ request });

Type guard

function isFormRequest(contentType: string): boolean {
  const base = contentType.split(';')[0].trim();
  return base === 'application/x-www-form-urlencoded' || base === 'multipart/form-data';
}

Try / catch

try {
  state = await getActionState({ request });
} catch (err) {
  if (String(err?.message).includes('form request')) {
    state = null; // JSON submission path; no React state expected
  } else throw err;
}

Prevention

When it happens

Trigger: Calling getActionState({ request }) inside an action defined without accept: 'form' (Astro defaults to JSON and the client posts JSON); invoking the action via fetch with a JSON body; calling getActionState from an API route that received a non-form request.

Common situations: Adding useActionState to an existing JSON action without flipping accept; progressive-enhancement setups where some submissions are programmatic JSON; forgetting that the accept option controls the wire format.

Related errors


AI-assisted analysis of withastro/astro@52e6c34790 (2026-08-18). Data as JSON: /api/errors/c5a4275b86d4e22e. Report an issue: GitHub.

Appendix: source

Thrown at packages/integrations/react/src/actions.ts:50

	// 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<typeof callback>) =>
			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 withState} helper.
 */
export async function getActionState<T>({ request }: { request: Request }): Promise<T> {
	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 `withState()` wrapper.',
		);
	}
	return JSON.parse(state) as T;
}

const formContentTypes = ['application/x-www-form-urlencoded', 'multipart/form-data'];

function isFormRequest(contentType: string) {

View on GitHub (pinned to 52e6c34790)