Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 57 additions & 47 deletions README.md
Original file line number Diff line number Diff line change
@@ -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 `<form action={action}>` 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 `<input>`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<State, Payload extends FormData>(
action: (state: Awaited<State>, payload: Payload) => State,
permalink?: string,
): [
/**
* The action to use in a `<form>` 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<State> | null,
];
```

## Clone it the example
#### Usage

```sh
git clone [email protected]: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<State, Payload>(
action: (state: Awaited<State>, payload: Payload) => State,
permalink?: string,
): [
dispatch: (payload: Payload) => Promise<State>,
// "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`
- `<UseActionProvider>` is used to populate this to the `useAction()` handler

Additional:
## What we're missing here

- Make `<form>`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
- `<form>`'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 [email protected]:KATT/react-server-action-useActionState-useFormState-issues.git
cd react-server-action-useActionState-useFormState-issues
git checkout feat/hack
pnpm i
pnpm dev
```
40 changes: 11 additions & 29 deletions app/_createUser.tsx
Original file line number Diff line number Diff line change
@@ -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<CreateUserState> {
export const createUser = createAction<FormData>()(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");
});
29 changes: 17 additions & 12 deletions app/layout.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<html lang="en">
<body className={inter.className}>
{children}
<UseActionProvider value={getUseActionProviderValue()}>
<html lang="en">
<body className={inter.className}>
{props.children}

<Toaster />
</body>
</html>
<Toaster />
</body>
</html>
</UseActionProvider>
);
}
21 changes: 10 additions & 11 deletions app/page.tsx
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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 (
// 😷 `<form action={action}>` makes the form work differently with or without JS enabled (inputs should clear)
<form action={action} className="space-y-4 shadow p-4">
{/* 😷 State is serialized as a hidden input here -- unnecessary payload, `<input type="hidden" name="$ACTION_1:0" value="{&quot;id&quot;:&quot;4a156bb69b4bf838c9c71c23a01294921f53ff23&quot;,&quot;bound&quot;:&quot;$@1&quot;}">` */}
{state.errors && (
{output?.errors && (
<ErrorToastOrBox>
<>Errors: {JSON.stringify(state.errors, null, 2)}</>
<>Errors: {JSON.stringify(output.errors, null, 2)}</>
</ErrorToastOrBox>
)}
<div className="flex flex-col space-y-1">
Expand All @@ -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
/>
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions use-action/browser.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export const ENV = "browser" as string;

export { UseActionProvider, useAction } from "./context/shared";
13 changes: 13 additions & 0 deletions use-action/context/server.tsx
Original file line number Diff line number Diff line change
@@ -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];
}
59 changes: 59 additions & 0 deletions use-action/context/shared.tsx
Original file line number Diff line number Diff line change
@@ -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 | UseActionProviderValue>(
null,
);

export function useAction<State, Payload extends FormData>(
action: (state: Awaited<State>, payload: Payload) => State,
permalink?: string,
): [
/**
* The action to use in a `<form>` 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<State> | null,
] {
const ctx = use(UseActionContext);
const [state, dispatch] = useFormState<State>(
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 (
<UseActionContext.Provider value={props.value}>
{props.children}
</UseActionContext.Provider>
);
}
10 changes: 10 additions & 0 deletions use-action/lib/betterRedirect.ts
Original file line number Diff line number Diff line change
@@ -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<typeof redirect>) {
return redirect(...args) as unknown as undefined;
}
Loading