diff --git a/_posts/2024-12-11-react-forms.md b/_posts/2024-12-11-react-forms.md
new file mode 100644
index 0000000..e0988c2
--- /dev/null
+++ b/_posts/2024-12-11-react-forms.md
@@ -0,0 +1,347 @@
+---
+layout: single
+title: Progressive Forms with React 19
+date: 2024-12-11
+---
+So, React 19 is here! And Server Components and Forms are now the blessed way. It's like old-school backend-first web-dev all over again but with two great advantages:
+1. Full-stack type-safety
+2. You can inject client-side interactivity when needed
+
+_In the beginning_, the only way with React was single-page-apps (SPAs) with mountains of clunky state and AJAX. Things improved and Routers were invented, and [useQuery](https://tanstack.com/query/latest/docs/framework/react/reference/useQuery) made data fetching and management easier. But state is _hard_! Every read or mutation has several layers where state can persist, and subtle interdependencies. Not to mention there's no graceful downgrade if JavaScript isn't available, and you have to ship loads of the stuff to make it all work on the client.
+
+[Remix](https://remix.run/) pushed hard on forms and backend routing. Hit a form, reload. Next.js saw that this was good, so they created the App router and a nightmare transition for their users, but the dust is now settling (at least for greenfield projects). And now all this stuff has found its way into React itself, in the form [React Server Components](https://react.dev/reference/rsc/server-components) and new Form tooling.
+
+But they're new and slightly weird and the best patterns for some basic things still aren't obvious. Specifically:
+1. Data validation
+2. Handling errors
+3. Maintaining state
+4. Optimistic loading
+
+So I played around a bit and came up with what I think is a pretty good setup for fancy React 19 forms. I'm going to assume you're already familiar with with Server Components and `"use server"` and forms in general. If not, it's worth reading [the Next.js docs on the topic](https://nextjs.org/docs/app/building-your-application/data-fetching/server-actions-and-mutations) and following the interesting links where they lead. As always, you can just skip to the repo [carderne/react-forms](https://github.com/carderne/react-forms) (or scroll to the bottom) if you prefer.
+
+## Starting point
+A basic server action with some [zod](https://zod.dev/) validation. You can also use [zod-form-data](https://www.npmjs.com/package/zod-form-data) to make some of this more ergonomic.
+```ts
+// actions.ts
+"use server";
+import { redirect } from "next/navigation";
+import { z } from "zod";
+
+const schema = z.object({
+ todo: z.string().min(3, { message: "Please write more!" }),
+});
+
+export async function addItemAction(
+ formData: FormData
+) {
+ const obj = Object.fromEntries(formData);
+ const data = schema.parse(obj);
+ console.log(data); // or, you know, persist to DB
+ redirect("/");
+}
+```
+
+And a form to use it.
+```tsx
+// form.tsx
+import Form from "next/form";
+import { addItemAction } from "./actions";
+
+export function ItemForm() {
+ return (
+
+ )
+}
+```
+
+That works pretty well. And it's so simple compared to "modern" ways of mutating data. You persist the data, redirect as needed, and wherever the user lands will load the data. This means all the complex state stuff stays in the database, where it belongs.
+## Error handling
+The only problem with the code above is that it does nothing for errors. For example, the `todo` field requires a minimum of 3 characters. If the user enters only two, it will throw and the user will get an error page.
+
+So of course you use `schema.safeParse(...)` but then what do you do with the error? This is where React 19 comes in, with the new [useActionState](https://react.dev/reference/react/useActionState) hook. It wraps your action and gives you a `state` object where your server code can return errors and messages for the client.
+
+On the backend, we return an object with an `errors` field (you're obviously free to call this whatever you want). And we can use some handy `zod` methods to create error messages keyed to the schema fields.
+```ts
+// actions.ts
+"use server";
+import { redirect } from "next/navigation";
+import { z } from "zod";
+
+const schema = z.object({
+ todo: z.string().min(3, { message: "Please write more!" }),
+});
+
+export interface AddItemState {//+
+ errors: { todo: string[] }//+
+}//+
+
+export async function addItemAction(
+ _state: AddItemState,//+
+ formData: FormData
+): Promise {//+
+ const obj = Object.fromEntries(formData);
+ const { data, error } = schema.safeParse(obj);
+ if (error) {//+
+ return {//+
+ errors: error.flatten().fieldErrors//+
+ }//+
+ }//+
+ console.log(data);
+ redirect("/");
+}
+```
+
+And the form. When there's a validation error from entering a too-short string, it'll display the "Please write more!" message above the input. We also get a nice Loading state with the `pending` value from the hook.
+```tsx
+// form.tsx
+"use client"; // this must be client side now//+
+
+import Form from "next/form";
+import { useActionState } from "react";//+
+import { addItemAction, type AddItemState } from "./actions";//+
+
+export function ItemForm() {
+ const [state, formAction, pending] = useActionState(//+
+ addItemAction, // our action//+
+ {}, // and default state//+
+ );//+
+ return (
+
+ );
+}
+```
+
+## Maintaining client state
+That's a big improvement but there's still one problem: every time you hit a validation error, the form will reset. This is to maintain parity with native forms, which reset as soon as they're submitted. There's a massive thread at [facebook/react#29034](https://github.com/facebook/react/issues/29034) discussing this{%- include fn.html n=1 -%}, with two main approaches shared for getting around this:
+
+1. Submit the form manually using `onSubmit`
+2. Return all the original form data as part of the `AddItemState` return value.
+
+I'm going to show the second option, mostly because it lets us get further without resorting to imperative logic, and it seems a bit more elegant. But with very complex (multi-step) forms, some combination of the two could be required.
+
+Here's our code again. I added some type helpers that you can re-use wherever you have a form. These take any `zod` schema and create a neat return type with the `FormData` plus an array of error messages for each field in the schema.
+```ts
+// types.ts
+type InferFieldErrors = {
+ [K in keyof z.infer]?: string[] | undefined;
+};
+export type ActionState = {
+ formData?: FormData;
+ errors?: InferFieldErrors;
+};
+```
+
+And here's the action. The key differences being the fact that we now return `formData` in the error path (and the new `AddItemState` created using the helpers above).
+```ts
+// actions.ts
+"use server";
+import { redirect } from "next/navigation";
+import { z } from "zod";
+import { type ActionState } from "./types";//+
+
+const addItemSchema = z.object({
+ todo: z.string().min(3, { message: "Text must be longer" }),
+});
+export type AddItemState = ActionState;//+
+
+export async function addItemAction(
+ _state: AddItemState,
+ formData: FormData,
+): Promise {
+ const formDataObj = Object.fromEntries(formData);
+ const { data, error } = addItemSchema.safeParse(formDataObj);
+ if (error) {
+ return {
+ formData, // we return the formData as-is//+
+ errors: error.flatten().fieldErrors,
+ };
+ }
+ console.log(data);
+ redirect("/");
+}
+```
+
+And the form. The type cast on the `defaultValue` isn't wonderful, and there are cases with `