Skip to content

Commit

Permalink
docs: useFormState (#55564)
Browse files Browse the repository at this point in the history
This PR shows how to use a new React hook `useFormState` in the context
of the [Forms and
Mutations](https://nextjs.org/docs/app/building-your-application/data-fetching/forms-and-mutations)
docs. It also updates the forms example (`next-forms`) to show the
recommended patterns for loading / error states.

Related: #55399
---
Co-authored-by: John Pham <johnphammail@gmail.com>
  • Loading branch information
leerob and JohnPhamous committed Sep 22, 2023
1 parent c923257 commit 5f4238d
Show file tree
Hide file tree
Showing 9 changed files with 321 additions and 103 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -64,31 +64,64 @@ export default function PostList({ posts }) {

You can use [`usePathname()`](/docs/app/api-reference/functions/use-pathname) to determine if a link is active. For example, to add a class to the active link, you can check if the current `pathname` matches the `href` of the link:

```jsx filename="app/ui/Navigation.js"
```tsx filename="app/components/links.tsx"
'use client'

import { usePathname } from 'next/navigation'
import Link from 'next/link'

export function Navigation({ navLinks }) {
export function Links() {
const pathname = usePathname()

return (
<>
{navLinks.map((link) => {
const isActive = pathname === link.href
<nav>
<ul>
<li>
<Link className={`link ${pathname === '/' ? 'active' : ''}`} href="/">
Home
</Link>
</li>
<li>
<Link
className={`link ${pathname === '/about' ? 'active' : ''}`}
href="/"
>
About
</Link>
</li>
</ul>
</nav>
)
}
```

```jsx filename="app/components/links.jsx"
'use client'

import { usePathname } from 'next/navigation'
import Link from 'next/link'

export function Links() {
const pathname = usePathname()

return (
return (
<nav>
<ul>
<li>
<Link className={`link ${pathname === '/' ? 'active' : ''}`} href="/">
Home
</Link>
</li>
<li>
<Link
className={isActive ? 'text-blue' : 'text-black'}
href={link.href}
key={link.name}
className={`link ${pathname === '/about' ? 'active' : ''}`}
href="/"
>
{link.name}
About
</Link>
)
})}
</>
</li>
</ul>
</nav>
)
}
```
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -360,14 +360,16 @@ export default async function submit(formData) {

<AppOnly>

Use the `useFormStatus` hook to show a loading state when a form is submitting on the server:
Use the `useFormStatus` hook to show a loading state when a form is submitting on the server. The `useFormStatus` hook can only be used as a child of a `form` element using a Server Action.

```tsx filename="app/page.tsx" switcher
For example, the following submit button:

```tsx filename="app/submit-button.tsx" switcher
'use client'

import { experimental_useFormStatus as useFormStatus } from 'react-dom'

function SubmitButton() {
export function SubmitButton() {
const { pending } = useFormStatus()

return (
Expand All @@ -376,12 +378,12 @@ function SubmitButton() {
}
```

```jsx filename="app/page.jsx" switcher
```jsx filename="app/submit-button.jsx" switcher
'use client'

import { experimental_useFormStatus as useFormStatus } from 'react-dom'

function SubmitButton() {
export function SubmitButton() {
const { pending } = useFormStatus()

return (
Expand All @@ -390,9 +392,40 @@ function SubmitButton() {
}
```

> **Good to know:**
>
> - Displaying loading or error states currently requires using Client Components. We are exploring options for server-side functions to retrieve these values as we move forward in stability for Server Actions.
`<SubmitButton />` can then be used in a form with a Server Action:

```tsx filename="app/page.tsx" switcher
import { SubmitButton } from '@/app/submit-button'

export default async function Home() {
return (
<form action={...}>
<input type="text" name="field-name" />
<SubmitButton />
</form>
)
}
```

```jsx filename="app/page.jsx" switcher
import { SubmitButton } from '@/app/submit-button'

export default async function Home() {
return (
<form action={...}>
<input type="text" name="field-name" />
<SubmitButton />
</form>
)
}
```

<details>
<summary>Examples</summary>

- [Form with Loading & Error States](https://github.com/vercel/next.js/tree/canary/examples/next-forms)

</details>

</AppOnly>

Expand Down Expand Up @@ -484,89 +517,116 @@ export default function Page() {

<AppOnly>

Server Actions can also return [serializable objects](https://developer.mozilla.org/docs/Glossary/Serialization). For example, your Server Action might handle errors from creating a new item, returning either a success or error message:
Server Actions can also return [serializable objects](https://developer.mozilla.org/docs/Glossary/Serialization). For example, your Server Action might handle errors from creating a new item:

```ts filename="app/actions.ts" switcher
'use server'

export async function create(formData: FormData) {
export async function create(prevState: any, formData: FormData) {
try {
await createItem(formData.get('item'))
revalidatePath('/')
return { message: 'Success!' }
await createItem(formData.get('todo'))
return revalidatePath('/')
} catch (e) {
return { message: 'There was an error.' }
return { message: 'Failed to create' }
}
}
```

```js filename="app/actions.js" switcher
'use server'

export async function create(formData) {
export async function createTodo(prevState, formData) {
try {
await createItem(formData.get('item'))
revalidatePath('/')
return { message: 'Success!' }
await createItem(formData.get('todo'))
return revalidatePath('/')
} catch (e) {
return { message: 'There was an error.' }
return { message: 'Failed to create' }
}
}
```

Then, from a Client Component, you can read this value and save it to state, allowing the component to display the result of the Server Action to the viewer.
Then, from a Client Component, you can read this value and display an error message.

```tsx filename="app/page.tsx" switcher
```tsx filename="app/add-form.tsx" switcher
'use client'

import { create } from './actions'
import { useState } from 'react'
import { experimental_useFormState as useFormState } from 'react-dom'
import { experimental_useFormStatus as useFormStatus } from 'react-dom'
import { createTodo } from '@/app/actions'

export default function Page() {
const [message, setMessage] = useState<string>('')
const initialState = {
message: null,
}

async function onCreate(formData: FormData) {
const res = await create(formData)
setMessage(res.message)
}
function SubmitButton() {
const { pending } = useFormStatus()

return (
<button type="submit" aria-disabled={pending}>
Add
</button>
)
}

export function AddForm() {
const [state, formAction] = useFormState(createTodo, initialState)

return (
<form action={onCreate}>
<input type="text" name="item" />
<button type="submit">Add</button>
<p>{message}</p>
<form action={formAction}>
<label htmlFor="todo">Enter Task</label>
<input type="text" id="todo" name="todo" required />
<SubmitButton />
<p aria-live="polite" className="sr-only">
{state?.message}
</p>
</form>
)
}
```

```jsx filename="app/page.jsx" switcher
```jsx filename="app/add-form.jsx" switcher
'use client'

import { create } from './actions'
import { useState } from 'react'
import { experimental_useFormState as useFormState } from 'react-dom'
import { experimental_useFormStatus as useFormStatus } from 'react-dom'
import { createTodo } from '@/app/actions'

export default function Page() {
const [message, setMessage] = useState('')
const initialState = {
message: null,
}

async function onCreate(formData) {
const res = await create(formData)
setMessage(res.message)
}
function SubmitButton() {
const { pending } = useFormStatus()

return (
<form action={onCreate}>
<input type="text" name="item" />
<button type="submit">Add</button>
<p>{message}</p>
<button type="submit" aria-disabled={pending}>
Add
</button>
)
}

export function AddForm() {
const [state, formAction] = useFormState(createTodo, initialState)

return (
<form action={formAction}>
<label htmlFor="todo">Enter Task</label>
<input type="text" id="todo" name="todo" required />
<SubmitButton />
<p aria-live="polite" className="sr-only">
{state?.message}
</p>
</form>
)
}
```
> **Good to know:**
>
> - Displaying loading or error states currently requires using Client Components. We are exploring options for server-side functions to retrieve these values as we move forward in stability for Server Actions.
<details>
<summary>Examples</summary>
- [Form with Loading & Error States](https://github.com/vercel/next.js/tree/canary/examples/next-forms)
</details>
</AppOnly>
Expand Down
22 changes: 13 additions & 9 deletions examples/next-forms/app/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { z } from 'zod'
// text TEXT NOT NULL
// );

export async function createTodo(formData: FormData) {
export async function createTodo(prevState: any, formData: FormData) {
const schema = z.object({
todo: z.string().nonempty(),
})
Expand All @@ -24,25 +24,29 @@ export async function createTodo(formData: FormData) {
`

revalidatePath('/')

return { message: 'Saved successfully' }
return { message: `Added todo ${data.todo}` }
} catch (e) {
return { message: 'Failed to create todo' }
}
}

export async function deleteTodo(formData: FormData) {
export async function deleteTodo(prevState: any, formData: FormData) {
const schema = z.object({
id: z.string().nonempty(),
})
const data = schema.parse({
id: formData.get('id'),
})

await sql`
DELETE FROM todos
WHERE id = ${data.id};
`
try {
await sql`
DELETE FROM todos
WHERE id = ${data.id};
`

revalidatePath('/')
revalidatePath('/')
return { message: `Deleted todo ${data.todo}` }
} catch (e) {
return { message: 'Failed to delete todo' }
}
}
34 changes: 34 additions & 0 deletions examples/next-forms/app/add-form.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
'use client'

import { experimental_useFormState as useFormState } from 'react-dom'
import { experimental_useFormStatus as useFormStatus } from 'react-dom'
import { createTodo } from '@/app/actions'

const initialState = {
message: null,
}

function SubmitButton() {
const { pending } = useFormStatus()

return (
<button type="submit" aria-disabled={pending}>
Add
</button>
)
}

export function AddForm() {
const [state, formAction] = useFormState(createTodo, initialState)

return (
<form action={formAction}>
<label htmlFor="todo">Enter Task</label>
<input type="text" id="todo" name="todo" required />
<SubmitButton />
<p aria-live="polite" className="sr-only" role="status">
{state?.message}
</p>
</form>
)
}
Loading

0 comments on commit 5f4238d

Please sign in to comment.