Dev Highlights

Доки по разработке

This project is maintained by teniryte

2. Формы и Server Actions в Next.js

На основе модуля 13-forms ваш проект демонстрирует практически полный спектр приёмов работы с формами в App Router: useActionState, валидация на сервере, обновление сущностей, оптимистичные обновления, вложенные формы и программная отправка.

2.1. Базовый паттерн: server action + форма

Идея: мы определяем функцию с директивой 'use server', которая:

Клиентская страница вызывает эту server action через:

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,
  };
}

2.2. Создание пользователя с useActionState

В вашем модуле 01-create-user-useActionState используется мощный паттерн:

Ключевые идеи:

Пример клиентской страницы с 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>
  );
}

Что важно запомнить по паттерну:

2.3. Обновление сущности: проброс параметров в server action

В модуле 02-update-user можно увидеть паттерн:

Идея: 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);
}

Принципы:

2.4. startTransition и управляемые формы на клиенте

Модуль 03-start-transition демонстрирует работу с:

Сценарий: есть поле поиска по массиву фруктов, при вводе:

Паттерн:

'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>
    </>
  );
}

Принципы:

2.5. Оптимистичные обновления с useOptimistic

Модуль 04-optimistic-updates — отличный пример:

Server actions:

Серверный компонент‑страница:

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>
    </>
  );
}

Принципы оптимистичных обновлений:

2.6. Вложенные формы и 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);
}

Принципы:

2.7. Программная отправка формы

Модуль 06-programmatic-submission показывает:

Паттерн программной отправки:

'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);
}

Когда полезно:

2.8. Валидация с Zod и проверка уникальности

В вашем createUserAction используется Zod для валидации данных, включая асинхронную проверку уникальности email в базе.

Паттерн:

Пример схемы:

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 символов'),
});

Принципы:

2.9. Итоги по формам и server actions

Кратко по тем паттернам, которые вы уже использовали:

Эти паттерны покрывают почти все реальные сценарии работы с формами в Next.js и отлично подходят как «шпаргалка» при разработке. В следующих главах они будут сочетаться с ISR, lazy loading, MDX и другими возможностями.