New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Actions #898
Comments
Moved this from the Stage 1, posted this right before it moved to Stage 2: I am not super familiar with the subject at hand (I think in the past 10 years, I've only written a One thing I'll just mention is that I saw people float the idea of having two files with the same names but a different extension (like |
@Princesseuh I agree with this! During goal setting, we also found a more fundamental issue following SvelteKit's spec for look-behind files: all pages with a A common example: a blog with a like button. Each post in your blog would include the like button, and submitting a "click" would trigger a form action to update your database counter. Using the SvelteKit convention, you would duplicate your // src/pages/[...slug].ts
export const actions = {
like: defineAction(...),
} However, this now means You might think "well, form actions do require server handling. Maybe that refactor is justified." This is fair! However, there are alternative APIs we can explore to keep refactors to a minimum for the developer. One solution would be to keep action declarations to standalone endpoint files, like a |
Where should actions live?I want to outline a few places where actions may be defined:
src/pages/blog/api.ts
export const actions = {
like: defineAction(...),
} To call these actions from a
Right now, I actually lean towards (4). I admittedly wanted to like (3) in this list, since I value colocation with my pages. However, I quickly found myself centralizing actions to a I also realized colocation isn't self-explanatory. We want colocation... with what? After working on Astro Studio, I realized we didn't use colocation with presentational UI in // src/procedures/trpc.ts
app({
user: userRouter, // from ./users.ts
github: githubRouter, // from ./github.ts
project: projectRouter, // from ./projects/.ts
});
// in your app
trpc.user.auth()
trpc.github.createTemplate()
trpc.project.create() We could create a similar organization with actions/
user.ts
github.ts
project.ts
index.ts import { userActions } from './user';
import { githubActions } from './github';
import { projectActions } from './project';
export const actions = {
user: userActions,
github: githubActions,
project: projectActions,
}
// in your app
actions.user.auth()
actions.github.createTemplate()
actions.project.create() This pattern mirrors REST-ful APIs as well, which I hope feels intuitive for existing Astro users. I glossed over specifics on calling these actions from a |
I would not recommend this. POST requests with form data are considered "simple requests" and ignored by browsers' same origin policy. Even if Astro implements a CSRF protection by default, some devs are going to disable it to enable cross-origin and cross-site requests and not implement CSRF protection since they assume their actions only accept JSON (which is subjected to SOP). |
Thanks for sharing that nuance @pilcrowOnPaper. I know Astro recently added built-in CSRF protection across requests, but I see what you mean about the payloads having different behavior. It sounds like we shouldn't implicitly let you pass form data or json to the same action. How would you feel if the action explicitly defined the payload it is willing to accept? Assuming a Zod setup for validation, it may look like the following: const like = defineAction({
input: formData(), // accepts form data
handler: () => {}
});
const comment = defineAction({
input: z.object(), // accepts json
handler: () => {}
}); |
TL;DR: Make forData opt-in by exporting additional utilities or mapping
@bholmesdev I just recently spend some effort using @conform-to/zod's Safely parsing form data, and maybe validating it against a schema sounds like a good goal to me. Here's how I currently use forms in Astro: // admin/discounts/create.astro
---
import { createForm } from 'lib/forms.ts'
const discountSchema = z.object({
description: z.string().optional().default(""),
limit: z.number().int().min(1).default(1),
code: z.string().optional(),
})
const { data, fields, isValid, errors } = await createForm(discountSchema, Astro.request)
// createForm builds fields with required attributes from schema, checks for `request.method=='POST'
// and parses with Zod to data if valid, or errors otherwise
if(isValid) {
try { await storeOnServer(data) } catch {}
}
---
<form method="post">
<label>Code</label>
<input {...fields.code} /> /* type="text" name="code" */
<small>{errors.code?.message}</small>
<label>Limit</label>
<input {...fields.limit} /> /* type="number" name="limit" min="1" */
<small>{errors.limit?.message}</small>
<label>Description</label>
<textarea {...fields.description} /> /* name="description" */
<small>{errors.description?.message}</small>
<button type="submit">Create</button>
</form> Exported actionThe below is very much my interopretation/stab at an API I personally would prefer using, and not so much what I think makes sense from a technical perspective. I think locating actions inside an actions folder is fine, especially if the resulting endpoints don't necessarily match the names or folder structure. That would allow collocation multiple related actions togethers // src/actions/cart.ts
export const sizes = ["S", "M", "L", "XL"];
const cartItem = z.object({
sku: z.string(),
size: z.enum(sizes),
amount: z.number()
})
export addToCart = createAction({
schema: cartItem,
handler: (item: z.infer<typeof cartItem>) => addToCart(item)
})
export updateCartItem = createAction({
schema: cartItem,
handler: (item: z.infer<typeof cartItem>) => updateCart(item)
})
// etc
Usage in formsOn the consumer side, I would use it like the following, perhaps: // pages/store/[product].astro
import { addToCart } from '../../actions/cart.ts'
const product = Astro.props
const { form, inputs } = addToCart
---
<form {...form.attrs}> /* action="/_actions/cart/addToCart" method="post" */
<label>Amount</label>
<input {...form.inputs.amount.attrs } />
<select {...form.inputs.size.attrs }>
{form.inputs.size.options.map(size => <option value={size}>{size}</option>)}
</select>
<input {...form.inputs.sku.attrs} type="hidden" value={product.sku} />
<button type="submit">Add to cart</button>
</form> If I would like to use the action as a full page form submission instead, maybe an other API could be something like: Which creates the above attributes and a form handler on this specific route. It would submit to the action/call its handler, with validated formData and additionally output errors/success state. // pages/store/[product].astro
import { addToCart } from '../../actions/cart.ts'
import { formAction } from 'astro:actions'
const product = Astro.props
const { form, inputs, success, errors } = await formAction(addToCart, Astro.request)
---
<form {...form.attrs}> /* action="" method="post" */
{success && <p>Added to cart</p>}
<label>Amount</label>
<input {...form.inputs.amount.attrs } />
{errors.amount && <small>{error.amount.message}</small>}
<select {...form.inputs.size.attrs }>
{form.inputs.size.options.map(size => <option value={size}>{size}</option>)}
</select>
{errors.size && <small>{error.size.message}</small>}
<input {...form.inputs.sku.attrs} type="hidden" value={product.sku} />
<button type="submit">Add to cart</button>
</form> Usage inside Framework ComponentsYou won't touch the exported form property at all and instead directly use the schema, url etc // components/addToCartButton.tsx
import { addToCart, sizes } from '../../actions/cart.ts'
export const AddToCartButton = (props) => {
const [amount, setAmount] = createSignal<z.infer<typeof addToCart.schema>['amount']>(1)
const [size, setSize] = createSignal<z.infer<typeof addToCart.schema>['size']>(sizes[0])
const [success, setSuccess] = createSignal(false)
const [errors, setErrors] = createSignal(null)
const submit = () => {
try {
await someHttpLib.post(addToCart.url, { amount: amount(), size: size(), sku: props.sku })
setSuccess(true)
} catch (e) {
setErrors(e)
}
}
return (<div>
<NumberField value={amount} onChange={setAmount} />
<Dropdown options={size} selected={size} onSelect={setSize} />
<Button onClick={submit} />
</div>)
} Could even take it up a notch and let submission and handling of the server action result be an exported function // components/addToCartButton.tsx
import { addToCart, sizes } from '../../actions/cart.ts'
export const AddToCartButton = (props) => {
const [amount, setAmount] = createSignal<z.infer<typeof addToCart.schema>['amount']>(1)
const [size, setSize] = createSignal<z.infer<typeof addToCart.schema>['size']>(sizes[0])
const [success, setSuccess] = createSignal(false)
const [errors, setErrors] = createSignal(null)
const submit = () => {
const { success, errors } = await addToCart.submit({ amount: amount(), size: size(), sku: props.sku })
setSuccess(success)
setErrors(errors)
}
return (<div>
<NumberField value={amount} onChange={setAmount} />
<Dropdown options={size} selected={size} onSelect={setSize} />
<Button onClick={submit} />
</div>)
} |
Thanks for that @robertvanhoesel! Good to see thorough overviews like this. I think we're pretty aligned on how we want form actions to feel. Pulling out a few pieces here:
|
I think if actions export enough data/schemas/utilities the very concrete implementation I showed above could be more of a library implementing the actions API rather than something that should be packed by default. Could still be part of the core offering but ultimately it starts becoming prescriptive in how to implement actions. In the end this is a pure DOM spec implementation to have some client side validation based on an action schema. It makes sense to live as
I have not considered a directive. Would I assume you mean something like: <input action:attributes={action.schema.myProperty} /> I'm not sure if I'm a fan, but perhaps that's because directives are sparsely used in Astro at the moment. I have considered building it into a component, but my main argument against it is that it would prevent you from using UI Framework components which enhance standard HTML Inputs with validation states or UX. I want to be able tp spread the validation attributes and field name onto a framework component, which has my preference over using some custom Is the below syntax possible in Astro (maybe it already is!), a component that can take a function as contents/default slot where the first param is the generated inputs/validation?---
import { Form } from 'astro:actions'
import { postComment } from '../actions/comments'
---
<Form action={postComment}>{(inputs) => (
<div>
<input {...inputs.name.attrs} />
<textarea {inputs.content.attrs} />
</div>
)}
</Form> Beyond this DOM topic:
Partial Page Action / Multiple FormsMy biggest frustration currently with native forms and handling forms in Astro is whenever you start having more than one form/action on a page. I currently make sure components that include a form, and can be on the same page as other form components, will check for some unique I even have a helper in my createForm for creating that scope on the submit and then also validating the scope value is set whenever a POST request on that route is triggered. const { fields, submitButton } = await createForm(schema, Astro.request, { scope: 'someIdentifier' })
---
<form>
...
<button {...submitButton} >
</form> I was initially excited for (Form) Actions in Astro because I hoped it would offer a fairly native solution to this without the need of client side UI components. So, that makes me wonder, can we adopt a baseline HTMX feature set for this. Combining Partial Route Components (so they can be routed to) with ViewTransitions. // pages/partials/like.astro
---
import { likeAction } from '../../actions/like.ts'
export const partial = true;
interface Props {
count: number
slug: string
}
let count = Astro.props.count
if(Astro.request.method==="POST") {
try {
count = await likeAction(Astro.request.formData()) // count++
} catch (e) { .... }
}
---
<form transition:replace action="partials/like" method="post">
{count} likes
<button type="submit"> Like</button>
<input type="hidden" name="slug" value={Astro.props.slug} />
</form> So we could do something like the below on routes that have ViewTransitions enabled. // pages/blogs/[slug].astro
---
import Like from '../../partials/like.astro'
const blog = Astro.props
---
<h1>{blog.title}</h1>
<LikeCount slug={blog.slug} count={blog.likes} /> If not the above, I'm really puzzled as to why to support formData out of the box in actions. In that case I'm a fan of Astro because I can sprinkle in frameworks and libraries where needed. It would be super amazing if we could have basic self replacing forms out of the box. |
Appreciate the input @robertvanhoesel. I'm hearing a few pieces in your feedback:
|
@bholmesdev your last bullet sounds a bit htmx-ish. |
Just to spit ball & throw it out there: I think the majority of the time a JSON payload is all anyone will ever need. The one / main use cases I can think of for when The main example being a user account settings page where the user can upload an avatar / image. The options in this scenario in my mind would be:
The other main scenario I could possibly wonder when |
Alright, we've been iterating on form actions with a lot through feedback from core, maintainers, and the general community. It seems the thoughts that resonated most were:
I'm excited to share a demo video for a proof-of-concept. We're already reconsidering some ideas noted here, namely removing the need for an https://www.loom.com/share/81a46fa487b64ee98cb4954680f3646e?sid=717eb237-4dd8-41ce-8612-1274b471c2ca |
Good feedback @NuroDev! I agree JSON is the best default, and we'll likely lead with that in our documentation examples. Still, the That's also a good callout on file uploads. It sounds like the tRPC team has encoded file blobs to JSON strings with reasonable success, but it's probably not the approach for everyone. At the very least, it would be nice to access the raw request body from a context variable if you need to go deep parsing inputs. |
Wait, how do you implement progressive enhancement with JSON? Re: input validation - I don't really see the benefit of integrating it into the core of form actions. Why can't it be a wrapper over |
As a counter point to this: Astro already offers input validation for stuff like content collections out of the box with Though that did pose me the question: If someone doesn't want any kind of validation, or wishes to use a different validation library (Valibot, Typebox, etc) would the 1st parameter of the handler just return the un-validated request body I assume? |
Alright, thanks again for the quality input everyone. We've finally hit a point that we can draft an RFC. It includes complete docs of all expected features, and describes any drawbacks or alternatives we've discussed here. Give it a read when you get the chance. Open to feedback as always. |
I took a brief look at #912 and it feels a tad overcomplicated and restrictive. I don't want to use The ideal API would allow defining functions in the same astro file as the form: ---
const doStuff = async (formData) => {
// do stuff with formData
}
---
<form method="POST" action={doStuff}>…</form> I see that this is listed as a non-goal, which is a bit unfortunate. What is the main problem with this? (i.e. what is meant by "misuse of variables"?) Today we can already handle form submissions in frontmatter, so this kinda feels natural to me? (at least when using SSR) As a compromise, it would be nice if actions were regular functions and allowed to be exported from anywhere (not just in // in src/wherever.js
export const doStuff = async (formData) => {
// do stuff with formData
} and imported anywhere, probably with import attributes: ---
import { doStuff } from "../wherever.js" with { type: "action" };
---
<form method="POST" action={doStuff}>…</form> The RFC says the main argument against this syntax is that users might forget to set the import attribute. (I feel like users deserve more credit than that, but anyway…) Maybe there should be runtime restriction around where the action is invoked from. e.g. Exampleexport const doStuff = async () => {}; import { doStuff } from "./doStuff" with { type: "action" };
import { invokeAction } from "astro:actions";
const handleFormSubmit = async (e) => {
e.preventDefault();
// throws if not used in .astro file
// doStuff();
// must wrap in invokeAction
const { stuff } = await invokeAction(doStuff)();
}
<form onSubmit={handleFormSubmit}…</form> Edit: Is it possible to implement it such that it throws if imported without the attribute? I feel like it should be possible using a helper at the place where the action is defined (rather than at the call-site). import { defineAction } from "astro:actions";
export const doStuff = defineAction(async () => {}); import { doStuff } from "./doStuff";
await doStuff(); // ❌ throws import { doStuff } from "./doStuff" with { type: "action" };
await doStuff(); // 👍 all good |
Hey @mayank99! Appreciate the detailed input here. It sounds like you see a bit too much overhead with I'll also admit export const server = {
comment: async (formData: FormData) => {...}
} Once we have an experimental release, it may be worth trying this alongside It also seems like you value colocation of actions within your Astro components. We agree, this seems intuitive. However, we had a few qualms with actions inside Astro frontmatter beyond what was listed in the non-goal:
Your import attributes idea would solve this issue.
|
@bholmesdev I appreciate you taking the time to dive deeper into my feedback! I enjoy this kind of nuanced discussion 💜
Normally I extract the fields like this: const { postId, title, body } = Object.fromEntries(formData); and validate them in varying ways: Manuallyif (typeof postId !== 'string') throw "Expected postId to be a string";
// … Using
|
@mayank99 Building on your comment on import attributes, you're right that they're not quite standardized yet, another possible, less elegant, but ready solution might be using import query params, e.g.
E.g. // src/components/Like.tsx
import { actions } from "~/actions/index.ts?action";
import { useState } from "preact/hooks";
export function Like({ postId }: { postId: string }) {
const [likes, setLikes] = useState(0);
return (
<button
onClick={async () => {
const newLikes = await actions.like({ postId });
setLikes(newLikes);
}}
>
{likes} likes
</button>
);
} |
Thanks for the input @mayank99 and @okikio! Let me speak to a few bits of feedback I'm hearing: First, it sounds like there's desire to handle validation yourself without an handler: (formData) => {
const { postId, title, body } = PostSchema.parse(Object.fromEntries(formData));
} I spent some time prototyping this, since I did like the simplified onboarding experience. However, there's a few problems with the approach:
const result = await actions.myAction.safe(formData);
if (isInputError(result.error)) {
result.error.fields.postId // autocompletion!
} In the end, it felt like a first-party action: defineAction({
accept: 'form',
handler: (formData) => {
formData.get('property')
}
}) Now, about the First, I'm pretty confident we can avoid an intermittent issues with virtual module types using this model (see astro:db). Types are inferred directly from the It also seems like colocation with individual components is valuable to y'all. I understand that. Still, I want to offer some thoughts: First, frontmatter closures are dangerous for reasons not discussed in the above examples. I can't forget a discussion with the Clerk CEO about an authentication issue their users were seeing. They expected the following to gate their action correctly: function Page() {
const user = auth();
function login() {
"use server";
if (user.session === 'expected') { /* do something */ }
}
return (<></>);
} Not only did this not check the user object from the action, but it encoded the user as a hidden form input to "resume" that state on the server. This is because components do not rerun when an action is called; only the action does. Unless Astro wants to adopt HTML diffing over client-side calls, we would have the same problem. Putting closures aside, there is a world using import attributes from a separate file. But there are a few troubles that make this difficult:
I'll also address Okikio's suggestion for a In the end, an Hope this makes sense! Our goal was to entertain every possible direction before landing on a release, so this is all appreciated. |
First of all, I love that you are adding this feature! I use SvelteKit in my job and the Superforms library has been a gamechanger so I am glad that Astro is adding something similar. Fun fact about the above conversation: the Superforms library just had a major rewrite to allow for more custom validation but that is besides my point haha. I was playing around with the feature on a project that I want to use this. On this project I need to set a Anyway, thanks for all this great work! |
Hey @cbasje! Glad you're enjoying the feature. You're right that the |
You can't, that's why actions being able to handle FormData is so important. Accessibility requirements mean that many large organisations and government departments mandate that their public facing (and they strongly recommend that their internal) apps and sites are fully functional without JS. Try using an SPA with screen reader software, and you'll quickly see why this isn't just about catering for people who've decided for whatever reason to disable JS in their browser. |
@ciw1973 @pilcrowOnPaper Adam Silver has a great blog post on this topic: |
@bholmesdev Finally got to play around with Actions today. Excellent job on the implementation and API, error handling and progressive enhancement (I like how One question I have: I can export an action from a route export const myAction = () => defineAction({ ... } |
Glad you like how actions are working @robertvanhoesel! And uh... I'm very surprised this works actually. Can you give a more complete example? 😅 |
Body
Summary
Let's make handling form submissions easy and type-safe 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.
The text was updated successfully, but these errors were encountered: