Доки по разработке
This project is maintained by teniryte
На основе модуля 13-forms ваш проект демонстрирует практически полный спектр приёмов работы с формами в App Router: useActionState, валидация на сервере, обновление сущностей, оптимистичные обновления, вложенные формы и программная отправка.
Идея: мы определяем функцию с директивой 'use server', которая:
FormData,Клиентская страница вызывает эту server action через:
action у <form>, либоuseActionState (как в вашем примере создания пользователя).Server action для создания пользователя (упрощённый паттерн):
'use server';
import { PrismaClient } from '@prisma/client';
import z from 'zod';
const prisma = new PrismaClient();
const schema = z.object({
name: z.string().min(1),
email: z.string().email(),
password: z.string().min(8),
});
export async function createUserAction(
prevState: any,
formData: FormData,
): Promise<{ errors: any; isSuccess: boolean; values: any }> {
const data = {
name: formData.get('name') as string,
email: formData.get('email') as string,
password: formData.get('password') as string,
};
const validated = await schema.safeParseAsync(data);
if (!validated.success) {
return {
errors: validated.error.flatten().fieldErrors,
isSuccess: false,
values: data,
};
}
const newUser = await prisma.user.create({ data });
console.log('User created:', newUser);
return {
errors: {},
isSuccess: true,
values: data,
};
}
useActionStateВ вашем модуле 01-create-user-useActionState используется мощный паттерн:
errors, isSuccess, values) хранится в React-состоянии через useActionState.Ключевые идеи:
useActionState(action, initialState) возвращает:
state — текущее состояние, которое вернула server action;formAction — функцию, которую можно указать в form action={formAction};isPending — флаг, идёт ли сейчас запрос.state.Пример клиентской страницы с useActionState:
'use client';
import { useActionState } from 'react';
import { createUserAction } from '@/features/users/actions/createUser.action';
import { SubmitButton } from './SubmitButton';
export default function CreateUserPage() {
const [state, formAction, isPending] = useActionState(createUserAction, {
errors: {},
isSuccess: false,
values: {},
});
if (state.isSuccess) {
return <div>User created successfully</div>;
}
return (
<form action={formAction}>
<div>
<input
type="text"
name="name"
placeholder="Name"
defaultValue={state.values?.name}
/>
{state.errors?.name && (
<p className="error-message">{state.errors.name}</p>
)}
</div>
<div>
<input
type="email"
name="email"
placeholder="Email"
defaultValue={state.values?.email}
/>
{state.errors?.email && (
<p className="error-message">{state.errors.email}</p>
)}
</div>
<div>
<input
type="password"
name="password"
placeholder="Password"
defaultValue={state.values?.password}
/>
{state.errors?.password && (
<p className="error-message">{state.errors.password}</p>
)}
</div>
<SubmitButton disabled={isPending} />
</form>
);
}
Кнопка отправки с useFormStatus:
'use client';
import { useFormStatus } from 'react-dom';
export function SubmitButton({ disabled }: { disabled?: boolean }) {
const { pending } = useFormStatus();
return (
<button type="submit" disabled={disabled || pending}>
Create
</button>
);
}
Что важно запомнить по паттерну:
useActionState отлично подходит для форм, где нужно возвращать богатое состояние (ошибки по полям, значения, флаги).В модуле 02-update-user можно увидеть паттерн:
/users/[userId],userId.Идея: form action={updateUserAction.bind(null, userId)} — мы «частично применяем» аргумент userId к server action.
Пример:
'use client';
import { use } from 'react';
import { updateUserAction } from '@/features/users/actions/updateUser.action';
export default function UpdateUserPage({
params,
}: {
params: Promise<{ userId: string }>;
}) {
const { userId } = use(params);
return (
<form action={updateUserAction.bind(null, userId)}>
<input type="text" name="name" placeholder="Name" />
<input type="email" name="email" placeholder="Email" />
<input type="password" name="password" placeholder="Password" />
<button type="submit">Update</button>
</form>
);
}
Server action для обновления:
'use server';
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
export async function updateUserAction(userId: string, formData: FormData) {
const data = Object.fromEntries(formData);
await prisma.user.update({
where: { id: userId },
data: {
name: data.name as string,
email: data.email as string,
password: data.password as string,
},
});
const user = await prisma.user.findUnique({ where: { id: userId } });
console.log('User updated:', user);
}
Принципы:
bind или обёртки, чтобы пробрасывать параметры (userId, postId и т.п.) в server actions.Object.fromEntries(formData), если нужно быстро собрать объект.startTransition и управляемые формы на клиентеМодуль 03-start-transition демонстрирует работу с:
useState),startTransition для пометки тяжёлых обновлений состояния как низкоприоритетных.Сценарий: есть поле поиска по массиву фруктов, при вводе:
startTransition,useEffect.Паттерн:
'use client';
import { startTransition, useState, useEffect } from 'react';
const fruits = ['apple', 'banana', 'cherry', 'orange', 'mango'];
export default function SearchPage() {
const [search, setSearch] = useState('');
const [filteredFruits, setFilteredFruits] = useState<string[]>(fruits);
const [isLoading, setIsLoading] = useState(false);
const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setIsLoading(true);
const value = e.target.value;
setSearch(value);
startTransition(() => {
setFilteredFruits(
fruits.filter((fruit) => fruit.toLowerCase().includes(value.toLowerCase())),
);
setIsLoading(false);
});
};
useEffect(() => {
console.log('SEARCH', search);
}, [search]);
return (
<>
<input value={search} onChange={handleSearchChange} />
{isLoading && <p>Loading...</p>}
<ul>
{filteredFruits.map((fruit) => (
<li key={fruit}>{fruit}</li>
))}
</ul>
</>
);
}
Принципы:
startTransition полезен, когда обновление состояния может быть тяжёлым (фильтрация, сортировка, ререндер больших списков).useOptimisticМодуль 04-optimistic-updates — отличный пример:
messages из БД через Prisma;Messages отображает их и использует useOptimistic;Server actions:
createMessage(formData) — создаёт сообщение и вызывает revalidatePath(...);deleteMessage(id) — удаляет сообщение и также вызывает revalidatePath(...).Серверный компонент‑страница:
import { PrismaClient } from '@prisma/client';
import { Messages } from './Messages';
const prisma = new PrismaClient();
export default async function MessagesPage() {
const messages = await prisma.message.findMany();
return (
<>
<h1>Optimistic Updates</h1>
<Messages messages={messages} />
</>
);
}
Клиентский компонент с useOptimistic:
'use client';
import { useOptimistic } from 'react';
import { createMessage, deleteMessage } from './actions';
type Message = { id: string; content: string };
export function Messages({ messages }: { messages: Message[] }) {
const [optimisticMessages, addOptimisticMessage] = useOptimistic<
Message[],
{ content: string }
>(messages, (state, { content }) => [
...state,
{ content, id: `$${Math.random()}` }, // временный ID
]);
const formAction = async (formData: FormData) => {
addOptimisticMessage({
content: formData.get('content') as string,
});
await createMessage(formData); // Подтверждаем на сервере
};
return (
<>
<h2>Messages</h2>
<div>
{optimisticMessages.map((message) => (
<div
key={message.id}
className={message.id.startsWith('$') ? 'opacity-50' : ''}
>
<p>{message.content}</p>
<button
disabled={message.id.startsWith('$')}
onClick={() => deleteMessage(message.id)}
>
Delete
</button>
</div>
))}
{!messages.length && <p>No messages</p>}
</div>
<h2>Add Message</h2>
<form action={formAction}>
<input name="content" placeholder="Message" />
<button type="submit">Add</button>
</form>
</>
);
}
Принципы оптимистичных обновлений:
useOptimistic) до ответа сервера.$), чтобы отличать элементы, ещё не подтверждённые сервером.revalidatePath, чтобы серверный компонент получил свежие данные.formAction на кнопкахМодуль 05-nested-form-elements показывает удобный паттерн:
Идея: атрибут formAction у <button> позволяет указать серверную функцию, которая будет вызвана при отправке через эту кнопку.
Пример:
'use client';
import { saveAsDraft } from './actions/saveAsDraft';
import { submit } from './actions/submit';
export function NestedForm() {
return (
<>
<h2>Nested Form</h2>
<form>
<input type="text" name="title" placeholder="Title" />
<button formAction={saveAsDraft}>Save as Draft</button>
<button formAction={submit}>Submit</button>
</form>
</>
);
}
Server actions:
'use server';
export async function submit(formData: FormData) {
console.log('SUBMIT', formData);
}
export async function saveAsDraft(formData: FormData) {
console.log('SAVE_AS_DRAFT', formData);
}
Принципы:
<form>, но несколько разных «режимов» отправки.formAction позволяет чисто отделить сценарии «Сохранить черновик» и «Отправить».Модуль 06-programmatic-submission показывает:
useRef для доступа к DOM‑форме;formRef.current.submit() и formRef.current.requestSubmit();Паттерн программной отправки:
'use client';
import { useRef } from 'react';
import { submitAction } from './actions/submitAction';
export function ProgrammaticForm() {
const formRef = useRef<HTMLFormElement | null>(null);
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && e.ctrlKey) {
e.preventDefault();
if (e.shiftKey) {
console.log('force submit');
formRef.current?.submit(); // отправка без валидации браузера
} else {
console.log('normal submit');
formRef.current?.requestSubmit(); // стандартная отправка, с валидацией
}
}
};
return (
<>
<h2>Programmatic Form</h2>
<form ref={formRef} action={submitAction}>
<div>
<label htmlFor="name">Name</label>
<input name="name" minLength={3} required />
</div>
<div>
<label htmlFor="email">Email</label>
<input
name="email"
type="email"
required
pattern="[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}$"
/>
</div>
<div>
<label htmlFor="bio">Bio</label>
<textarea name="bio" onKeyDown={handleKeyDown} />
</div>
<button type="submit">Submit</button>
</form>
</>
);
}
Server action:
'use server';
export async function submitAction(formData: FormData) {
console.log('SUBMIT_ACTION', formData);
}
Когда полезно:
Ctrl+Enter.В вашем createUserAction используется Zod для валидации данных, включая асинхронную проверку уникальности email в базе.
Паттерн:
z.object({...});.refine(async (value) => ...) для асинхронных проверок;Пример схемы:
import z from 'zod';
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
const schema = z.object({
name: z.string().min(1, 'Имя обязательно'),
email: z
.string()
.email('Некорректный email')
.refine(
async (email) => {
const existingUser = await prisma.user.findUnique({ where: { email } });
return !existingUser;
},
{
message: 'Пользователь с таким email уже зарегистрирован',
},
),
password: z.string().min(8, 'Минимум 8 символов'),
});
Принципы:
required, minLength), но окончательное решение — за сервером.Кратко по тем паттернам, которые вы уже использовали:
'use server';FormData, работа с БД, валидация;form action={...} или formAction из useActionState.useActionState:
useOptimistic):
revalidatePath для синхронизации с сервером.formAction на кнопках:
<form>, несколько сценариев отправки (черновик/публикация и т.п.).requestSubmit, submit):
Эти паттерны покрывают почти все реальные сценарии работы с формами в Next.js и отлично подходят как «шпаргалка» при разработке. В следующих главах они будут сочетаться с ISR, lazy loading, MDX и другими возможностями.