Skip to content

Commit 8292880

Browse files
authored
docs: amends to preserving-ui-state guide (vercel#92270)
Still drafting this one. We want to show good patterns, and effects are often a last resource. We favor here resetting in an event handler.
1 parent 7f8ba2d commit 8292880

1 file changed

Lines changed: 75 additions & 56 deletions

File tree

docs/01-app/02-guides/preserving-ui-state.mdx

Lines changed: 75 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -140,95 +140,114 @@ function ProductTab() {
140140

141141
With this approach, `isDialogOpen` derives from the URL rather than component state. When navigating away and returning, the search param is cleared (the URL changed), so `isDialogOpen` becomes `false`. Opening the dialog sets the param, which changes `isDialogOpen` and triggers the Effect.
142142

143-
### Form input values
143+
### Forms, inputs, and state
144144

145-
Activity preserves form input values: text typed into fields, selected options, checkbox states.
145+
Activity preserves form input values (text fields, selected options, checkbox states), submission results, and status messages across navigations.
146146

147147
**When to keep it:** A search page with filters, a draft the user was composing, or a settings form with unsaved changes. Preserving input state is one of the biggest UX wins because the user doesn't lose work.
148148

149-
**When to reset it:** A "create new item" page where returning should start fresh, or a contact form after successful submission.
149+
**When to reset it:** A "new transaction" flow where each visit should start fresh, or a form where stale success/error messages would be confusing in a new context.
150150

151-
To reset form fields when Activity hides the component, use a callback ref:
151+
#### Resetting form state on submit
152152

153-
```tsx
154-
<form
155-
ref={(form) => {
156-
// Cleanup function - runs when Activity hides this component
157-
return () => form?.reset()
158-
}}
159-
>
160-
{/* fields */}
161-
</form>
162-
```
153+
Consider a page where the user creates a new item. After submitting, `router.push` navigates to the new record. Since Activity preserves the page, navigating back shows the previous name still in the form. Reset state in the event handler to keep the form fresh:
163154

164-
This resets the form whenever the user navigates away.
155+
```tsx filename="app/new/page.tsx" highlight={13}
156+
'use client'
165157

166-
### Action state (`useActionState`)
158+
import { useState } from 'react'
159+
import { useRouter } from 'next/navigation'
167160

168-
Activity preserves [`useActionState`](https://react.dev/reference/react/useActionState) results: success messages, error messages, and any other state returned by the action.
161+
export default function NewItemPage() {
162+
const [name, setName] = useState('')
163+
const router = useRouter()
169164

170-
**When to keep it:** A ticket redemption form showing "Ticket redeemed successfully", or a settings form showing "Changes saved". Seeing the result of a previous action when returning to the page is useful confirmation so the user can see what happened.
165+
async function handleSubmit(e: React.FormEvent) {
166+
e.preventDefault()
167+
const item = await createItem({ name })
168+
setName('')
169+
router.push(`/items/${item.id}`)
170+
}
171171

172-
**When to reset it:** A "new transaction" flow where each visit should start fresh, or a form where stale success/error messages would be confusing in a new context.
172+
return (
173+
<form onSubmit={handleSubmit}>
174+
<input value={name} onChange={(e) => setName(e.target.value)} />
175+
<button type="submit">Create</button>
176+
</form>
177+
)
178+
}
179+
```
173180

174-
You can think of `useActionState` as a `useReducer` that allows side effects. It doesn't have to only handle form submissions; you can dispatch any action to it. Adding a `RESET` action gives you a clean way to clear state when Activity hides the component (see [Reset state](https://react.dev/reference/react/useActionState#reset-state) in the React docs):
181+
#### Resetting stale status messages
175182

176-
```tsx highlight={5-6,9-21,26-35}
177-
'use client'
183+
If after submitting you set a status into state to render a feedback message, there's often not a reliable user-initiated event to clear it.
178184

179-
import { useActionState, useLayoutEffect, useRef, startTransition } from 'react'
185+
The user might navigate via `next/link` elements out of your control, or via browser controls.
180186

181-
type Action = { type: 'SUBMIT'; data: FormData } | { type: 'RESET' }
182-
type State = { success: boolean; error: string | null }
187+
Navigating back to the form shows a stale message. In this case, you may use a `useLayoutEffect` cleanup to reset the form and state:
183188

184-
function CommentForm() {
185-
const [state, dispatch, isPending] = useActionState(
186-
async (prev: State, action: Action) => {
187-
if (action.type === 'RESET') {
188-
return { success: false, error: null }
189-
}
190-
// Handle the form submission
191-
const res = await saveComment(action.data)
192-
if (!res.ok) return { success: false, error: res.message }
193-
shouldReset.current = true
194-
return { success: true, error: null }
195-
},
196-
{ success: false, error: null }
197-
)
189+
```tsx highlight={17-26}
190+
'use client'
191+
192+
import { useState, useRef, useLayoutEffect } from 'react'
198193

194+
function ContactForm() {
195+
const [name, setName] = useState('')
196+
const [status, setStatus] = useState<'idle' | 'success'>('idle')
199197
const shouldReset = useRef(false)
200198

201-
// Dispatch RESET when Activity hides this component
199+
async function handleSubmit(e: React.FormEvent) {
200+
e.preventDefault()
201+
await sendMessage({ name })
202+
setStatus('success')
203+
shouldReset.current = true
204+
}
205+
206+
// Reset stale success message when Activity hides this component
202207
useLayoutEffect(() => {
203208
return () => {
204209
if (shouldReset.current) {
205210
shouldReset.current = false
206-
startTransition(() => {
207-
dispatch({ type: 'RESET' })
208-
})
211+
setStatus('idle')
212+
setName('')
209213
}
210214
}
211-
}, [dispatch])
215+
}, [])
212216

213217
return (
214-
<form action={(formData) => dispatch({ type: 'SUBMIT', data: formData })}>
215-
<textarea name="comment" />
216-
<button type="submit" disabled={isPending}>
217-
{isPending ? 'Posting...' : 'Post Comment'}
218-
</button>
219-
{state.success && <p>Comment posted!</p>}
220-
{state.error && <p>{state.error}</p>}
218+
<form onSubmit={handleSubmit}>
219+
<input value={name} onChange={(e) => setName(e.target.value)} />
220+
<button type="submit">Send</button>
221+
{status === 'success' && <p>Message sent!</p>}
221222
</form>
222223
)
223224
}
224225
```
225226

226-
Here's what happens step by step:
227+
The `shouldReset` ref ensures the cleanup only runs after a successful submission. If the user navigates away mid-draft without submitting, their input is preserved.
228+
229+
If you use [`useActionState`](https://react.dev/reference/react/useActionState), the same approach applies. See [Reset state](https://react.dev/reference/react/useActionState#reset-state) in the React docs for how to add a `RESET` action to your reducer.
230+
231+
<details>
232+
<summary>Resetting all form fields with a callback ref</summary>
233+
234+
You can use a callback ref to call `form.reset()` when Activity hides the component:
235+
236+
```tsx
237+
<form
238+
ref={(form) => {
239+
return () => form?.reset()
240+
}}
241+
>
242+
<input name="email" />
243+
<input name="message" />
244+
<button type="submit">Send</button>
245+
</form>
246+
```
247+
248+
This resets all fields whenever the user navigates away.
227249

228-
1. The user submits the form. The reducer receives a `SUBMIT` action with the `FormData`, calls `saveComment`, and returns `{ success: true }`. It also sets `shouldReset.current = true` to mark that a reset is needed.
229-
2. The user navigates away. Activity hides the component and runs the `useLayoutEffect` cleanup. Because `shouldReset.current` is `true`, it dispatches a `RESET` action.
230-
3. The reducer receives `RESET` and returns the initial state (`{ success: false, error: null }`). The stale success message is cleared.
231-
4. If the user navigates back, the form is ready for a new submission. If they never submitted (step 1 didn't happen), `shouldReset.current` is still `false`, so no `RESET` is dispatched. The form stays as-is.
250+
</details>
232251

233252
## State and authentication
234253

0 commit comments

Comments
 (0)