Form actions #871
Replies: 5 comments 9 replies
-
Type safety seems challenging. Since endpoints return Response objects, I don't think their body type can be easily retrieved. Tools like nuxt (through nitro through h3), trpc or remix either require returning objects or use helper functions |
Beta Was this translation helpful? Give feedback.
-
Ecosystem solutionsThere are a variety of solutions to handling form / server actions throughout the JS ecosystem. Let's discuss the design of each, understand their goals, and see what aspects we could bring to an Astro solution. RemixRemix was among the first JS frameworks to make "actions" a primitive. Actions are defined at the route or layout level using by exporting an import type { ActionFunctionArgs } from "@remix-run/node";
import { redirect } from "@remix-run/node";
import { Form } from "@remix-run/react";
import { fakeCreateTodo } from "~/utils/db";
export async function action({
request,
}: ActionFunctionArgs) {
const body = await request.formData();
const todo = await fakeCreateTodo({
title: body.get("title"),
});
return redirect(`/todos/${todo.id}`);
}
export default function Todos() {
return (
<Form method="post">
<input type="text" name="title" />
<button type="submit">Create Todo</button>
</Form>
);
} This offers clear wins for colocation and embracing browser standards. However, there are drawbacks to mirroring the API in Astro:
SvelteKitSvelteKit follows Remix's approach pretty closely. Routes and layouts can define an This solves handling for multiple forms on a single page. SvelteKit also uses type generation for action results using a React server actionsServer actions are the most recent take on forms from the JS community. Unlike Remix or SvelteKit, which use REST endpoints called by URL, server actions use remote procedure calls (RPCs) to call server-side functions directly from client code. This is possible with the // app/actions.ts
export async function addToCart(formData: FormData) {
"use server";
const name = formData.get('name');
const quantity = formData.get('quantity');
await db.insert('product').values({ userId, quantity, name });
}
// app/page.tsx
import { addToCart } from './actions';
export default async function Page() {
// Apply as server form action
return (
<form action={addToCart}>
<input type="hidden" name="name" value="flip-flops" />
<input type="number" name="quantity" />
</form>
)
}
// app/CheckoutButton.tsx
"use client";
import { addToCart } from './actions';
function CheckoutButton() {
// Call from client code
return <button onClick={() => addToCart(/* form data */)}>Check out</button>
} Server actions are the most flexible solution in the JS ecosystem today. Actions can be declared in any file, and frameworks like Next.js will auto-generate an endpoint whenever their bundler finds an action. This is great for library authors, who can add endpoints to any Next.js project by exporting functions with The However, that flexibility comes with tradeoffs: Forgetting the Server actions generate endpoint paths. Generated endpoints are part of an action's power, but it's a double-edged sword for debugging. If a server action fails, server logs will can only include the generated endpoint name and stacktrace info. It is tough to track down a failing action in your codebase, and doubly tough for multiple server actions with the same function name. File-based routing solutions do not have this problem. Last, server actions can be declared within a function closure. This has caused trouble in the Next.js community. Take the following "add to cart" example that authenticates the user with Clerk: import { auth } from '@clerk/nextjs';
import { db } from 'not/astro/db';
export async function Dashboard() {
const user = await auth();
async function addToCart(formData: FormData) {
"use server";
const userId = user.id;
const name = formData.get('name');
const quantity = formData.get('quantity');
await db.insert('product').values({ userId, quantity, name });
}
return (
<form action={addToCart}>
<input type="hidden" name="name" value="flip-flops" />
<input type="number" name="quantity" />
</form>
)
} Note that Astro could adopt Import attributesSeveral Astro core members have considered import attributes for safely calling server actions from client code. This stage 3 proposal introduces metadata attributes for any import using a import { addToCart } from './actions' with { type: 'action' }; This addresses a major concern with What's more, it's unclear if import attributes are meant to transform a file's contents. The import attributes proposal focuses on enforcing MIME types when importing non-JS extensions. Astro would be the first tool to bring import attributes to the bundler. Even if attributes can be used as a bundler hint, Vite has yet to support import attributes. Astro excels at finding new solutions grounded in the web platform. But betting on a stage 3 proposal that does not have our use case in mind is a risk. In fact, Where to go from hereOf course, there is no "perfect" solution. Though from the drawbacks for each solution above, we can move forward with a stronger Astro proposal. For now, I want to leave room for discussion before presenting solutions myself. Open to your thoughts! |
Beta Was this translation helpful? Give feedback.
-
This would be great! I really enjoy SvelteKit's, here's a half-baked idea ---
import { z, defineAction, Form } from "astro:action";
export const actions = {
register: defineAction({
schema: z.object({
name: z.string(),
email: z.string().email(),
}),
post: async ({ parsedData }) => {
parsedData.name; // string
parsedData.email; //string
// task
if (error) return { success: false };
return { success: true, ...parsedData };
},
}),
};
const { register } = Astro.form;
register.post; // { error: ZodError } | { success: false } | { success: true, name: string, email: string }
---
<Form method="post" action="/register">
<input type="text" name="name" />
<input type="email" name="email" />
<button>Submit</button>
</Form> The Excited to use this feature however it ends up! |
Beta Was this translation helpful? Give feedback.
-
My biggest issues with server actions in frameworks like Next.js and SolidStart, where you define a function that runs on the server that can be called from the client, are:
Given those 2 points, I'd prefer an API similar to SvelteKit, where you rather define handlers for each route in a separate file: // +page.server.ts
export actions = {
default: async (request: Request) => {
// ...
}
} So the idea I feel most comfortable right now is to allow users to define both // pages/index.astro
---
---
<form method="post">
<input name="message"/>
<button>Send</button>
</form> // pages/index.ts
// we could probably provide more high level APIs here
export async function actions(context: ActionContext) {
const message = context.formData.get("message");
// ...
}
I could also see Astro allowing you to define regular As for input validation, I think this should be up to the dev. Or, at the very least, you should be able to opt-out of the Zod validation. For error handling, it could be as simple as allowing the users to pass an error // pages/index.astro
---
---
<form method="post">
<input name="message" value="{Astro.form.values.get("message")}"/>
<p>{Astro.form.errors.get("message")}</p>
<button>Send</button>
</form> // pages/index.ts
// we could probably provide more high level APIs here
export async function actions(context: ActionContext) {
const message = context.formData.get("message");
// ...
errors.set("message", "Message too long");
} Astro could even export a framework-agnostic API to call APIs from the client. import { submitForm } from "astro/client";
const formData = new FormData();
// populate FormData
const result = await submitForm(formData);
const error = result.errors.get("message"); |
Beta Was this translation helpful? Give feedback.
-
Thank you for your work on this @bholmesdev I am a big fan of the Adonisjs framework and their library Vinejs for validation may be something to look at as it is open source and works on Nodejs. Perhaps it would offer some ideas/inspiration around implementations. Either way form story is the missing piece for me with Astro. Thank you! |
Beta Was this translation helpful? Give feedback.
-
Summary
This kicks off a discussion to handle form submissions both server-side and client-side.
Goals
Content-Type
.!
oras string
casts as in the exampleformData.get('expected')! as string
.form
element with theaction
property.useActionState()
anduseFormStatus()
hooks in React 19.hybrid
output.Non-goals
required
andtype
properties on an input..astro
frontmatter. Frontmatter forms a function closure, which can lead to misuse of variables within an action handler. This challenge is shared bygetStaticPaths()
and it would be best to avoid repeating this pattern in future APIs.Background & Motivation
Form submissions are a core building block of the web that Astro has yet to form an opinion on (pun intended).
So far, Astro has been rewarded for waiting on the platform and the Astro community to mature before designing a primitive. By waiting on view transitions, we found a SPA-like routing solution grounded in native APIs. By waiting on libSQL, we found a data storage solution for content sites and apps alike. Now, we've waited on other major frameworks to forge new paths with form actions. This includes Remix actions, SvelteKit actions, React server actions, and more.
At the same time, Astro just launched its database primitive: Astro DB. This is propelling the Astro community from static content to more dynamic use cases:
To meet our community where it's heading, Astro needs a form submission story.
The problem with existing solutions
Astro presents two solutions to handling form submissions with today's primitives. Though capable, these tools are either too primitive or present unacceptable tradeoffs for common use cases.
JSON API routes
JSON API routes allow developers to handle POST requests and return a JSON response to the client. Astro suggests this approach with a documentation recipe, demonstrating how to create an API route and handle the result from a Preact component.
However, REST endpoints are overly primitive for basic use cases. The developer is left to handle parse errors and API contracts themselves, without type safety on either side. To properly handle all error cases, this grows complex for even the simplest of forms:
REST boilerplate example
The client should also guard against malformed response values. This is accomplished through runtime validation with Zod, or a type cast to the response the client expects. Managing this contract in both places leaves room for types to fall out-of-sync. The manual work of defining and casting types is also added complexity that the Astro docs avoid for beginner use cases.
What's more, there is no guidance to progressively enhance this form. By default, a browser will send the form data to the
action
field specified on the<form>
element, and rerender the page with theaction
response. This default behavior is important to consider when a user submits a form before client JS has finished parsing, a common concern for poor internet connections.However, we cannot apply our API route as the
action
. Since our API route returns JSON, the user would be greeted by a stringified JSON blob rather than the refreshed contents of the page. The developer would need to duplicate this API handler into the page frontmatter to return HTML with the refreshed content. This is added complexity that our docs understandably don't discuss.View transition forms
View transitions for forms allow developers to handle a submission from Astro frontmatter and re-render the page with a SPA-like refresh.
This avoids common pitfalls with MPA form submissions, including the "Confirm resubmission?" dialog a user may receive attempting to reload the page. This solution also progressively enhances based on the default form
action
handler.However, handling submissions from the page's frontmatter is prohibitive for static sites that cannot afford to server-render every route. It also triggers unnecessary work when client-side update is contained. For example, clicking "Likes" in this example will re-render the blog post and remount all client components without the
transition:persist
decorator.Last, the user is left to figure out common needs like optimistic UI updates and loading states. The user can attach event listeners for the view transition lifecycle, though we lack documentation on how to do so from popular client frameworks like React.
Beta Was this translation helpful? Give feedback.
All reactions