diff --git a/README.md b/README.md index b891bf8..6df682d 100644 --- a/README.md +++ b/README.md @@ -1,61 +1,71 @@ -# Challenges with the `useFormState()` & `useActionState()` hooks +# Evening-hack alternative to `useActionState()` / `useFormState()` -One of the key advantages, for me, of `useFormState()`/`useActionState()` and `
` is their ability to create isomorphic/universal forms that are progressively enhanced (fancy words for saying that forms work well with and without JS). +## Summary -However, the current API lacks some nuance needed for isomorphic forms. This repository aims to showcase those issues. +- **Background:** see the [README of the `main`-branch](https://github.com/KATT/react-server-action-useActionState-useFormState-issues/tree/main) +- No notion of "default state" when calling the hook +- No need of returning input values in order to re-render ``s values in SSR +- Not 2 arguments on actions, there's only 1 - your input -- **tl;dr:** Search the code for "😷" to see my perceived issues. -- **tl;dr2:** Most of my headaches would disappear if `useFormState()`/`useActionState()` returned `Payload` which would be the data that was last successfully sent to the server. +### API -> Either I'm dumb or the API needs some refining. Maybe "current" values and errors should be stored in like a session variable? Maybe the server actions API isn't designed for returning anything at all? Should it be a Redirect ([Post/Get/Redirect](https://en.wikipedia.org/wiki/Post/Redirect/Get)) on every response? But then it will be weird when JS is enabled. +```tsx +function useAction( + action: (state: Awaited, payload: Payload) => State, + permalink?: string, +): [ + /** + * The action to use in a `` element. + */ + dispatch: (payload: Payload) => State, + /** + * Will be `null` if no payload is available. + */ + payload: null | Payload, + /** + * Will be `null` if no state is available. + */ + state: Awaited | null, +]; +``` -## Clone it the example +#### Usage -```sh -git clone git@github.com:KATT/react-server-action-useActionState-useFormState-issues.git -cd react-server-action-useActionState-useFormState-issues -pnpm i -pnpm dev -``` +- https://github.com/KATT/react-server-action-useActionState-useFormState-issues/blob/bb5c53788973a954c5549c846f711bc0ba15611b/app/page.tsx#L27 +- https://github.com/KATT/react-server-action-useActionState-useFormState-issues/blob/bb5c53788973a954c5549c846f711bc0ba15611b/app/page.tsx#L54 -## Prior art on discussion +#### Setting it up -- https://allanlasser.com/posts/2024-01-26-avoid-using-reacts-useformstatus -- https://github.com/facebook/react/pull/28491#issuecomment-2015032940 -- https://github.com/facebook/react/pull/28491#issuecomment-2015585371 -- _[... something I'm missing?]_ +> Obviously, I don't think any of this wiring should be needed and that `payload` should be omnipresent in `useFormState()` -## Proposed API +- https://github.com/KATT/react-server-action-useActionState-useFormState-issues/blob/55943159220c0d82b8653747971e4b8662d2cb6f/app/layout.tsx#L22 +- https://github.com/KATT/react-server-action-useActionState-useFormState-issues/blob/55943159220c0d82b8653747971e4b8662d2cb6f/app/page.tsx#L27 +- https://github.com/KATT/react-server-action-useActionState-useFormState-issues/blob/55943159220c0d82b8653747971e4b8662d2cb6f/app/_createUser.tsx#L11 -```ts -function useActionState( - action: (state: Awaited, payload: Payload) => State, - permalink?: string, -): [ - dispatch: (payload: Payload) => Promise, - // "input" - payload: null | Payload, - // "output" - state: State, - pending: boolean; -]; -``` +Yay, now I can just use `input?.get("username")`: + +https://github.com/KATT/react-server-action-useActionState-useFormState-issues/blob/bb5c53788973a954c5549c846f711bc0ba15611b/app/page.tsx#L54 + +## How this hack works -- Add `payload` to the return of the hook - - it's good to use to set `defaultValue={}` - - serializing the payload from the action is gnarly as shown in this example - - returning payload for setting `defaultValue` is unnecessary - - (payload can be stripped from e.g. `File` etc which can't really be hydrated) -- Get rid of `State` as a required argument -- Get rid of the `State` argument on the server actions. - - It gets rid of the `State` being serialized in the input that is passed back-and-forth (that to me isn't even needed?) - - It even requires a disclaimer [like this](https://react.dev/reference/react-dom/hooks/useFormState#my-action-can-no-longer-read-the-submitted-form-data) - - It changes the server-side depending on _how_ you call it, which is kinda odd - - It goes against React's fundamental idea that components are "Lego bricks" that can be added wherever and the functionality is baked in. Server Actions could be part of npm libraries, but it's frail with the current design. +- `createAction()` wrapper hacks into Next.js' request storage and stores an `.actionPayload` with the submitted `FormData` +- `` is used to populate this to the `useAction()` handler -Additional: +## What we're missing here -- Make ``s with JavaScript mimic the behavior they have with JavaScript disabled (confirmed that it will changed by [@acdlite here](https://github.com/facebook/react/pull/28491#issuecomment-2015283772)) -- Maybe make `useActionState()` return an options object instead? 4 tuple values is a lot to keep track of (but that's easy to build abstractions that does) +This work is mainly focused on enhancing the "no-JS-experience": -> With the above API, it will be possible to make great forms in React without using any form libraries +- ``'s should clear after submission in JS +- `payload` is always `null` in the client, will be more highlighted when form clears in JS as well +- `payload` isn't tied to a specific action at the moment - `useAction(x)` shouldn't return payload of `useAction(y)` +- this is a hack, there's probably more + +### Play with it + +```sh +git clone git@github.com:KATT/react-server-action-useActionState-useFormState-issues.git +cd react-server-action-useActionState-useFormState-issues +git checkout feat/hack +pnpm i +pnpm dev +``` diff --git a/app/_createUser.tsx b/app/_createUser.tsx index ccbd061..3db8e14 100644 --- a/app/_createUser.tsx +++ b/app/_createUser.tsx @@ -1,51 +1,33 @@ "use server"; -import { redirect } from "next/navigation"; - -export type CreateUserState = { - errors?: { - username?: string; - }; - input?: { - username?: string; - }; +import { betterRedirect, createAction } from "use-action"; + +export type ValidationErrors = { + username?: string; }; const usernames = ["john", "jane"]; -export async function createUser( - _: CreateUserState, - payload: FormData, -): Promise { +export const createUser = createAction()(async (payload) => { console.log("Creating user with payload", payload); // wait 300ms await new Promise((resolve) => setTimeout(resolve, 300)); const values = Object.fromEntries(payload); - const errors: CreateUserState["errors"] = {}; - if (usernames.includes(values.username as string)) { + const errors: ValidationErrors = {}; + if (usernames.includes((values.username as string).trim().toLowerCase())) { errors.username = `Username '${values.username}' is already taken`; } - if (Object.keys(errors).length > 0) { - // 😷 Some issues: - // What I want to do here is to return the errors and the payload so that the form can be re-rendered with the errors and inputs. - // return { errors, payload }; - // 👆❌ This doesn't work because: - // - File is not serializable (makes sense? it should be automatically omitted?) - // - FormData is not serializable (Next.js issue?) + if (Object.keys(errors).length > 0) { + // ✅ I don't need to return anything about the payload here return { errors, - // 😷 Why do I even need to return this? - // 😷 I have to **pick** values. I can't use `Object.fromEntries(formData)` either since that includes a bunch of junk of React-internals - input: { - username: values.username as string, - }, }; } usernames.push(payload.get("username") as string); - redirect("/success"); -} + betterRedirect("/success"); +}); diff --git a/app/layout.tsx b/app/layout.tsx index d1d7a61..42a8ca5 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,27 +1,32 @@ import type { Metadata } from "next"; import { Inter } from "next/font/google"; -import "./globals.css"; import { Toaster } from "react-hot-toast"; +import { ENV, UseActionProvider, getUseActionProviderValue } from "use-action"; +import "./globals.css"; const inter = Inter({ subsets: ["latin"] }); +console.log({ ENV }); + export const metadata: Metadata = { title: "Create Next App", description: "Generated by create next app", }; -export default function RootLayout({ - children, -}: Readonly<{ - children: React.ReactNode; -}>) { +export default function RootLayout( + props: Readonly<{ + children: React.ReactNode; + }>, +) { return ( - - - {children} + + + + {props.children} - - - + + + + ); } diff --git a/app/page.tsx b/app/page.tsx index 8b62777..daea243 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,9 +1,9 @@ "use client"; -import { useFormState } from "react-dom"; -import { createUser } from "./_createUser"; import { useEffect } from "react"; import toast from "react-hot-toast"; +import { useAction } from "use-action"; +import { createUser } from "./_createUser"; /** * When JavaScript is available, this component will render a toast. @@ -24,19 +24,18 @@ function ErrorToastOrBox(props: { children: JSX.Element }) { ); } function CreateUserForm() { - const [state, action] = useFormState( - createUser, - // 😷 How come I have to define a default state? Default input/payload would make sense for setting default values - {}, - ); + const [action, input, output] = useAction(createUser); + // ^? + + console.log({ action, input, output }); return ( // 😷 `` makes the form work differently with or without JS enabled (inputs should clear) {/* 😷 State is serialized as a hidden input here -- unnecessary payload, `` */} - {state.errors && ( + {output?.errors && ( - <>Errors: {JSON.stringify(state.errors, null, 2)} + <>Errors: {JSON.stringify(output.errors, null, 2)} )}
@@ -51,8 +50,8 @@ function CreateUserForm() { id="username" name="username" placeholder="john" - // 😷 how come I have to return this from the backend / server action? It should be readily available in both places - defaultValue={state.input?.username} + // ✅ I can just grab `input` here + defaultValue={input?.get("username") as string} className="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" required /> diff --git a/package.json b/package.json index 094fb9f..3692138 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "react": "^18.2.0", "react-dom": "^18.2.0", "react-hot-toast": "^2.4.1", + "use-action": "link:./use-action", "zod": "^3.22.4" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d82b485..8330977 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,6 +17,9 @@ dependencies: react-hot-toast: specifier: ^2.4.1 version: 2.4.1(csstype@3.1.3)(react-dom@18.2.0)(react@18.2.0) + use-action: + specifier: link:./use-action + version: link:use-action zod: specifier: ^3.22.4 version: 3.22.4 diff --git a/use-action/browser.ts b/use-action/browser.ts new file mode 100644 index 0000000..62315b2 --- /dev/null +++ b/use-action/browser.ts @@ -0,0 +1,3 @@ +export const ENV = "browser" as string; + +export { UseActionProvider, useAction } from "./context/shared"; diff --git a/use-action/context/server.tsx b/use-action/context/server.tsx new file mode 100644 index 0000000..a256505 --- /dev/null +++ b/use-action/context/server.tsx @@ -0,0 +1,13 @@ +import { getRequestStorage } from "../lib/requestStorage"; +import { UseActionProviderValue } from "./shared"; + +export function getUseActionProviderValue(): UseActionProviderValue | null { + const storage = getRequestStorage(); + console.log("FROM SERVER", storage.actionPayload); + + if (!("actionPayload" in storage)) { + return null; + } + + return [storage.actionPayload]; +} diff --git a/use-action/context/shared.tsx b/use-action/context/shared.tsx new file mode 100644 index 0000000..145700a --- /dev/null +++ b/use-action/context/shared.tsx @@ -0,0 +1,59 @@ +"use client"; +import { createContext, use } from "react"; +import { useFormState } from "react-dom"; + +export type UseActionProviderValue = [ + payload: unknown, + // TODO add info about the action +]; +export const UseActionContext = createContext( + null, +); + +export function useAction( + action: (state: Awaited, payload: Payload) => State, + permalink?: string, +): [ + /** + * The action to use in a `` element. + */ + dispatch: (payload: Payload) => State, + /** + * Will be `null` if no payload is available. + */ + payload: null | Payload, + /** + * Will be `null` if no state is available. + */ + state: Awaited | null, +] { + const ctx = use(UseActionContext); + const [state, dispatch] = useFormState( + action as any, + null as any, + permalink, + ); + + let payload = (ctx?.[0] ?? null) as Payload | null; + if (payload && !(payload instanceof FormData)) { + const fd = new FormData(); + + for (const [key, value] of payload as any[]) { + (fd as FormData).append(key, value); + } + payload = fd as any; + } + + return [dispatch as any, payload, state]; +} + +export function UseActionProvider(props: { + children: React.ReactNode; + value: UseActionProviderValue | null; +}) { + return ( + + {props.children} + + ); +} diff --git a/use-action/lib/betterRedirect.ts b/use-action/lib/betterRedirect.ts new file mode 100644 index 0000000..01765fc --- /dev/null +++ b/use-action/lib/betterRedirect.ts @@ -0,0 +1,10 @@ +import { redirect } from "next/navigation"; + +/** + * Like Next.js' redirect but better represents what actually happens + * When a call to a server action redirects, it will actually `return` `undefined` + * @see https://github.com/vercel/next.js/issues/63771 + */ +export function betterRedirect(...args: Parameters) { + return redirect(...args) as unknown as undefined; +} diff --git a/use-action/lib/createAction.ts b/use-action/lib/createAction.ts new file mode 100644 index 0000000..c03ea71 --- /dev/null +++ b/use-action/lib/createAction.ts @@ -0,0 +1,23 @@ +import { storeActionPayload } from "./requestStorage"; + +/** + * Wraps an action function to store its input as action payload in the request storage. + */ +export function createAction() { + return function action(fn: (input: TInput) => Promise) { + return async function wrapper(...args: unknown[]) { + /** + * When you wrap an action with useFormState, it gets an extra argument as its first argument. + * The submitted form data is therefore its second argument instead of its first as it would usually be. + * The new first argument that gets added is the current state of the form. + * @see https://react.dev/reference/react-dom/hooks/useFormState#my-action-can-no-longer-read-the-submitted-form-data + */ + let input = args.length === 1 ? args[0] : args[1]; + + // store action payload so it can be used in server-side components + storeActionPayload(input); + + return await fn(input as TInput); + }; + }; +} diff --git a/use-action/lib/requestStorage.ts b/use-action/lib/requestStorage.ts new file mode 100644 index 0000000..ea4e6ca --- /dev/null +++ b/use-action/lib/requestStorage.ts @@ -0,0 +1,29 @@ +import { + getExpectedRequestStore, + RequestStore, +} from "next/dist/client/components/request-async-storage.external"; + +interface RequestStoreWithActionPayload extends RequestStore { + actionPayload?: unknown; +} +export function getRequestStorage() { + const storage = getExpectedRequestStore("getRequestStorage"); + + return storage as RequestStoreWithActionPayload; +} + +export function storeActionPayload(payload: unknown) { + const storage = getRequestStorage(); + let store = payload; + if (payload instanceof FormData) { + // create copy without File + store = new FormData(); + for (const [key, value] of payload as any) { + if (typeof value === "string") { + // omit e.g. File from new FormData + (store as FormData).append(key, value); + } + } + } + storage.actionPayload = store; +} diff --git a/use-action/package.json b/use-action/package.json new file mode 100644 index 0000000..addd1c1 --- /dev/null +++ b/use-action/package.json @@ -0,0 +1,10 @@ +{ + "name": "use-action", + "exports": { + ".": { + "types": "./types.ts", + "browser": "./browser.ts", + "default": "./server.ts" + } + } +} diff --git a/use-action/server.ts b/use-action/server.ts new file mode 100644 index 0000000..c7600fd --- /dev/null +++ b/use-action/server.ts @@ -0,0 +1,10 @@ +export const ENV = "server" as string; + +export { getUseActionProviderValue } from "./context/server"; + +export { useAction, UseActionProvider } from "./context/shared"; + +export { getRequestStorage } from "./lib/requestStorage"; + +export { createAction } from "./lib/createAction"; +export { betterRedirect } from "./lib/betterRedirect"; diff --git a/use-action/types.ts b/use-action/types.ts new file mode 100644 index 0000000..47d6c70 --- /dev/null +++ b/use-action/types.ts @@ -0,0 +1,2 @@ +export * from "./server"; +export {} from "./browser";