remix-run/react-router · error

Cannot handle form actions without a decodeAction function

Error message

Cannot handle form actions without a decodeAction function

What it means

Progressive-enhancement form posts (no JS) carry $ACTION_* keys in the FormData. Handling them requires React's decodeAction to rebuild the action from those fields; without it, processServerAction throws (lib/rsc/server.rsc.ts:723). decodeFormState is optional, decodeAction is not.

Solutions

  1. Pass decodeAction (and decodeFormState for useFormState support) from the RSC runtime into the server handler options
  2. Verify with a curl POST containing a $ACTION_ key that the server no longer throws
  3. If progressive enhancement is out of scope, ship forms that only submit via JS and document the limitation

Example fix

// before
const handler = createRequestHandler({ build, decodeReply, loadServerAction })
// after
const handler = createRequestHandler({
  build,
  decodeReply,
  loadServerAction,
  decodeAction,
  decodeFormState,
})
Defensive patterns

Strategy: validation

Validate before calling

// Startup check for progressive enhancement support
if (typeof decodeAction !== 'function') {
  console.warn('No-JS form submissions ($ACTION_*) will throw; pass decodeAction to the RSC handler');
}

Prevention

When it happens

Trigger: A form POST with $ACTION_* fields (JS disabled, curl test, bot, or a plain HTML POST) hitting an RSC handler created without decodeAction; smoke-testing forms via curl against a partially wired custom RSC server.

Common situations: Custom RSC server setups that wired only the fetch-action path (decodeReply) but not the form fallback; CI/curl tests that POST form encodings; crawlers replaying form submissions.

Related errors


AI-assisted analysis of remix-run/react-router@7aea711dd1 (2026-08-18). Data as JSON: /api/errors/b3cfa253d85014b5. Report an issue: GitHub.

Appendix: source

Thrown at packages/react-router/lib/rsc/server.rsc.ts:730

    let formData =
      maybeFormData &&
      typeof maybeFormData === "object" &&
      maybeFormData instanceof FormData
        ? maybeFormData
        : null;

    let skipRevalidation = formData?.has("$SKIP_REVALIDATION") ?? false;

    return {
      actionResult,
      revalidationRequest: getRevalidationRequest(),
      skipRevalidation,
    };
  } else if (isFormRequest) {
    const formData = await request.clone().formData();
    if (Array.from(formData.keys()).some((k) => k.startsWith("$ACTION_"))) {
      if (!decodeAction) {
        throw new Error(
          "Cannot handle form actions without a decodeAction function",
        );
      }
      const action = await decodeAction(formData);
      let formState = undefined;
      try {
        let result = await action();
        if (isRedirectResponse(result)) {
          result = prependBasenameToRedirectResponse(result, basename);
        }
        formState = await decodeFormState?.(result, formData);
      } catch (error) {
        if (isRedirectResponse(error)) {
          return prependBasenameToRedirectResponse(error, basename);
        }
        if (isResponse(error)) {
          return error;
        }

View on GitHub (pinned to 7aea711dd1)