← React/Server Actions и серверные мутации#294 из 383← ПредыдущийСледующий →+30 XP
Полезно по теме:Гайд: React или VueПрактика: React setТермин: React HooksТема: React: хуки и экосистема

Server Actions и серверные мутации

Что такое Server Actions

Server Actions — функции Next.js, которые выполняются на сервере, но вызываются из клиентского кода. Они решают задачу отправки данных на сервер без написания отдельного API-эндпоинта.

До Server Actions для мутации данных нужно было:

1. Создать API Route (/api/updateUser)

2. Сделать fetch('/api/updateUser', { method: 'POST', body: ... })

3. Обработать состояния загрузки, ошибок

Теперь: просто функция с 'use server'.

Директива "use server"

// app/actions.ts
'use server'  // все функции этого файла — серверные

import { db } from '@/lib/db'
import { revalidatePath } from 'next/cache'

export async function createPost(formData: FormData) {
  const title = formData.get('title') as string
  const content = formData.get('content') as string

  // Прямая работа с БД — безопасно, код не попадает в браузер!
  await db.posts.create({ data: { title, content } })

  // Инвалидируем кэш страницы с постами
  revalidatePath('/blog')
}

export async function deletePost(id: string) {
  await db.posts.delete({ where: { id } })
  revalidatePath('/blog')
}

Server Actions в формах

Самый простой способ использования — атрибут action у <form>:

// app/blog/new/page.tsx
import { createPost } from '../actions'

export default function NewPostPage() {
  return (
    <form action={createPost}>  {/* Server Action как action формы! */}
      <input name="title" placeholder="Заголовок" required />
      <textarea name="content" placeholder="Содержание" />
      <button type="submit">Опубликовать</button>
    </form>
  )
}
// Никакого fetch, никакого preventDefault — просто работает!

useFormStatus — статус отправки формы

'use client'
import { useFormStatus } from 'react-dom'

function SubmitButton() {
  const { pending } = useFormStatus()  // true пока форма отправляется

  return (
    <button type="submit" disabled={pending}>
      {pending ? 'Сохранение...' : 'Сохранить'}
    </button>
  )
}

// Использование:
function MyForm() {
  return (
    <form action={saveData}>
      <input name="value" />
      <SubmitButton />  {/* SubmitButton должен быть ВНУТРИ form */}
    </form>
  )
}

useOptimistic — оптимистичные обновления

Показывает результат до ответа сервера, откатывается при ошибке:

'use client'
import { useOptimistic } from 'react'
import { likePost } from './actions'

function LikeButton({ post }) {
  const [optimisticPost, addOptimisticLike] = useOptimistic(
    post,
    (state, newLikesCount) => ({ ...state, likes: newLikesCount })
  )

  async function handleLike() {
    // Обновляем UI сразу — не ждём сервера
    addOptimisticLike(optimisticPost.likes + 1)

    // Реальный запрос к серверу
    await likePost(post.id)
    // Если ошибка — автоматически откат к исходному состоянию
  }

  return (
    <button onClick={handleLike}>
      ❤️ {optimisticPost.likes}
    </button>
  )
}

useActionState (бывший useFormState)

'use client'
import { useActionState } from 'react'
import { createPost } from './actions'

// Сервер-экшн возвращает состояние
async function createPostWithState(prevState, formData) {
  'use server'
  const title = formData.get('title')
  if (!title) return { error: 'Заголовок обязателен', success: false }

  await db.posts.create({ data: { title } })
  return { error: null, success: true, message: 'Пост создан!' }
}

function CreatePostForm() {
  const [state, formAction, isPending] = useActionState(createPostWithState, {
    error: null,
    success: false,
  })

  return (
    <form action={formAction}>
      {state.error && <p style={{ color: 'red' }}>{state.error}</p>}
      {state.success && <p style={{ color: 'green' }}>{state.message}</p>}
      <input name="title" />
      <button disabled={isPending}>
        {isPending ? 'Создание...' : 'Создать'}
      </button>
    </form>
  )
}

Безопасность Server Actions

Server Actions выполняются на сервере, но их могут вызвать напрямую злоумышленники. Всегда проверяйте авторизацию:

'use server'
import { auth } from '@/lib/auth'
import { redirect } from 'next/navigation'

export async function deletePost(id: string) {
  // ВАЖНО: всегда проверяй аутентификацию!
  const session = await auth()
  if (!session) redirect('/login')

  // ВАЖНО: проверяй права доступа к конкретному ресурсу!
  const post = await db.posts.findUnique({ where: { id } })
  if (post.authorId !== session.user.id) {
    throw new Error('Нет прав для удаления этого поста')
  }

  await db.posts.delete({ where: { id } })
}

revalidatePath и revalidateTag

'use server'
import { revalidatePath, revalidateTag } from 'next/cache'

export async function updatePost(id, data) {
  await db.posts.update({ where: { id }, data })

  // Инвалидируем конкретный путь
  revalidatePath('/blog/' + id)

  // Или инвалидируем по тегу (более гибко)
  revalidateTag('posts')
  revalidateTag('post-' + id)
}

Примеры

Симуляция механизма Server Actions в ванильном JS: очередь действий, оптимистичные обновления и откат при ошибке

// Симулируем архитектуру Server Actions:
// очередь запросов, оптимистичный UI и rollback при ошибке.

// --- Симуляция сервера ---

const serverDB = {
  posts: [
    { id: 1, title: 'Первый пост', likes: 10 },
    { id: 2, title: 'Второй пост', likes: 5 },
  ]
}

// Серверная функция (имитирует сетевой запрос)
function serverLikePost(id) {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      const post = serverDB.posts.find(p => p.id === id)
      if (!post) {
        reject(new Error('Пост не найден'))
        return
      }
      post.likes++
      resolve({ id: post.id, likes: post.likes })
    }, 600)  // задержка 600мс
  })
}

// --- Оптимистичный стор ---

function createOptimisticUI(initialPosts) {
  let committed = initialPosts.map(p => ({ ...p }))   // подтверждённые данные
  let optimistic = initialPosts.map(p => ({ ...p }))  // отображаемые данные
  const pending = new Map()  // id -> { previousValue, action }

  return {
    // Оптимистичное обновление: применяем сразу
    optimisticUpdate(postId, updater) {
      const post = optimistic.find(p => p.id === postId)
      if (!post) return

      // Сохраняем предыдущее значение для отката
      pending.set(postId, {
        previousLikes: post.likes,
        timestamp: Date.now()
      })

      // Применяем обновление в UI немедленно
      updater(post)

      console.log('[UI] Оптимистично обновили пост', postId + ':', 'likes =', post.likes)
    },

    // Подтверждение: синхронизируем committed с сервером
    confirm(postId, serverData) {
      pending.delete(postId)
      const committedPost = committed.find(p => p.id === postId)
      const optimisticPost = optimistic.find(p => p.id === postId)
      if (committedPost) committedPost.likes = serverData.likes
      if (optimisticPost) optimisticPost.likes = serverData.likes
      console.log('[OK] Сервер подтвердил пост', postId + ': likes =', serverData.likes)
    },

    // Откат при ошибке
    rollback(postId, error) {
      const saved = pending.get(postId)
      if (!saved) return
      const post = optimistic.find(p => p.id === postId)
      if (post) post.likes = saved.previousLikes
      pending.delete(postId)
      console.log('[ERR] Откат поста', postId + ': likes вернулись к', saved.previousLikes)
      console.log('[ERR] Причина:', error.message)
    },

    getDisplay() { return optimistic },
    getPending() { return Array.from(pending.keys()) },
  }
}

// --- Имитация Server Action ---

async function likePostAction(store, postId) {
  // 1. Оптимистично обновляем UI
  store.optimisticUpdate(postId, post => { post.likes++ })

  // 2. Отправляем реальный запрос на "сервер"
  try {
    const result = await serverLikePost(postId)
    store.confirm(postId, result)
  } catch (err) {
    store.rollback(postId, err)
  }
}

// --- Запуск ---

async function main() {
  const store = createOptimisticUI(serverDB.posts)

  console.log('Начальное состояние:', store.getDisplay().map(p => p.title + ': ' + p.likes + ' лайков'))

  // Лайкаем пост 1 — успех
  console.log('
--- Лайк поста 1 ---')
  const like1 = likePostAction(store, 1)

  // Лайкаем пост 2 — успех
  console.log('--- Лайк поста 2 ---')
  const like2 = likePostAction(store, 2)

  // Попытка лайкнуть несуществующий пост — откат
  console.log('--- Лайк несуществующего поста 99 ---')
  const like3 = likePostAction(store, 99)

  console.log('
Пока ждём сервер, UI показывает:', store.getDisplay().map(p => p.title + ': ' + p.likes))
  console.log('Ожидают подтверждения:', store.getPending())

  await Promise.all([like1, like2, like3])

  console.log('
Итоговое состояние:', store.getDisplay().map(p => p.title + ': ' + p.likes + ' лайков'))
  console.log('Ожидают подтверждения:', store.getPending())
}

main()

Server Actions и серверные мутации

Что такое Server Actions

Server Actions — функции Next.js, которые выполняются на сервере, но вызываются из клиентского кода. Они решают задачу отправки данных на сервер без написания отдельного API-эндпоинта.

До Server Actions для мутации данных нужно было:

1. Создать API Route (/api/updateUser)

2. Сделать fetch('/api/updateUser', { method: 'POST', body: ... })

3. Обработать состояния загрузки, ошибок

Теперь: просто функция с 'use server'.

Директива "use server"

// app/actions.ts
'use server'  // все функции этого файла — серверные

import { db } from '@/lib/db'
import { revalidatePath } from 'next/cache'

export async function createPost(formData: FormData) {
  const title = formData.get('title') as string
  const content = formData.get('content') as string

  // Прямая работа с БД — безопасно, код не попадает в браузер!
  await db.posts.create({ data: { title, content } })

  // Инвалидируем кэш страницы с постами
  revalidatePath('/blog')
}

export async function deletePost(id: string) {
  await db.posts.delete({ where: { id } })
  revalidatePath('/blog')
}

Server Actions в формах

Самый простой способ использования — атрибут action у <form>:

// app/blog/new/page.tsx
import { createPost } from '../actions'

export default function NewPostPage() {
  return (
    <form action={createPost}>  {/* Server Action как action формы! */}
      <input name="title" placeholder="Заголовок" required />
      <textarea name="content" placeholder="Содержание" />
      <button type="submit">Опубликовать</button>
    </form>
  )
}
// Никакого fetch, никакого preventDefault — просто работает!

useFormStatus — статус отправки формы

'use client'
import { useFormStatus } from 'react-dom'

function SubmitButton() {
  const { pending } = useFormStatus()  // true пока форма отправляется

  return (
    <button type="submit" disabled={pending}>
      {pending ? 'Сохранение...' : 'Сохранить'}
    </button>
  )
}

// Использование:
function MyForm() {
  return (
    <form action={saveData}>
      <input name="value" />
      <SubmitButton />  {/* SubmitButton должен быть ВНУТРИ form */}
    </form>
  )
}

useOptimistic — оптимистичные обновления

Показывает результат до ответа сервера, откатывается при ошибке:

'use client'
import { useOptimistic } from 'react'
import { likePost } from './actions'

function LikeButton({ post }) {
  const [optimisticPost, addOptimisticLike] = useOptimistic(
    post,
    (state, newLikesCount) => ({ ...state, likes: newLikesCount })
  )

  async function handleLike() {
    // Обновляем UI сразу — не ждём сервера
    addOptimisticLike(optimisticPost.likes + 1)

    // Реальный запрос к серверу
    await likePost(post.id)
    // Если ошибка — автоматически откат к исходному состоянию
  }

  return (
    <button onClick={handleLike}>
      ❤️ {optimisticPost.likes}
    </button>
  )
}

useActionState (бывший useFormState)

'use client'
import { useActionState } from 'react'
import { createPost } from './actions'

// Сервер-экшн возвращает состояние
async function createPostWithState(prevState, formData) {
  'use server'
  const title = formData.get('title')
  if (!title) return { error: 'Заголовок обязателен', success: false }

  await db.posts.create({ data: { title } })
  return { error: null, success: true, message: 'Пост создан!' }
}

function CreatePostForm() {
  const [state, formAction, isPending] = useActionState(createPostWithState, {
    error: null,
    success: false,
  })

  return (
    <form action={formAction}>
      {state.error && <p style={{ color: 'red' }}>{state.error}</p>}
      {state.success && <p style={{ color: 'green' }}>{state.message}</p>}
      <input name="title" />
      <button disabled={isPending}>
        {isPending ? 'Создание...' : 'Создать'}
      </button>
    </form>
  )
}

Безопасность Server Actions

Server Actions выполняются на сервере, но их могут вызвать напрямую злоумышленники. Всегда проверяйте авторизацию:

'use server'
import { auth } from '@/lib/auth'
import { redirect } from 'next/navigation'

export async function deletePost(id: string) {
  // ВАЖНО: всегда проверяй аутентификацию!
  const session = await auth()
  if (!session) redirect('/login')

  // ВАЖНО: проверяй права доступа к конкретному ресурсу!
  const post = await db.posts.findUnique({ where: { id } })
  if (post.authorId !== session.user.id) {
    throw new Error('Нет прав для удаления этого поста')
  }

  await db.posts.delete({ where: { id } })
}

revalidatePath и revalidateTag

'use server'
import { revalidatePath, revalidateTag } from 'next/cache'

export async function updatePost(id, data) {
  await db.posts.update({ where: { id }, data })

  // Инвалидируем конкретный путь
  revalidatePath('/blog/' + id)

  // Или инвалидируем по тегу (более гибко)
  revalidateTag('posts')
  revalidateTag('post-' + id)
}

Примеры

Симуляция механизма Server Actions в ванильном JS: очередь действий, оптимистичные обновления и откат при ошибке

// Симулируем архитектуру Server Actions:
// очередь запросов, оптимистичный UI и rollback при ошибке.

// --- Симуляция сервера ---

const serverDB = {
  posts: [
    { id: 1, title: 'Первый пост', likes: 10 },
    { id: 2, title: 'Второй пост', likes: 5 },
  ]
}

// Серверная функция (имитирует сетевой запрос)
function serverLikePost(id) {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      const post = serverDB.posts.find(p => p.id === id)
      if (!post) {
        reject(new Error('Пост не найден'))
        return
      }
      post.likes++
      resolve({ id: post.id, likes: post.likes })
    }, 600)  // задержка 600мс
  })
}

// --- Оптимистичный стор ---

function createOptimisticUI(initialPosts) {
  let committed = initialPosts.map(p => ({ ...p }))   // подтверждённые данные
  let optimistic = initialPosts.map(p => ({ ...p }))  // отображаемые данные
  const pending = new Map()  // id -> { previousValue, action }

  return {
    // Оптимистичное обновление: применяем сразу
    optimisticUpdate(postId, updater) {
      const post = optimistic.find(p => p.id === postId)
      if (!post) return

      // Сохраняем предыдущее значение для отката
      pending.set(postId, {
        previousLikes: post.likes,
        timestamp: Date.now()
      })

      // Применяем обновление в UI немедленно
      updater(post)

      console.log('[UI] Оптимистично обновили пост', postId + ':', 'likes =', post.likes)
    },

    // Подтверждение: синхронизируем committed с сервером
    confirm(postId, serverData) {
      pending.delete(postId)
      const committedPost = committed.find(p => p.id === postId)
      const optimisticPost = optimistic.find(p => p.id === postId)
      if (committedPost) committedPost.likes = serverData.likes
      if (optimisticPost) optimisticPost.likes = serverData.likes
      console.log('[OK] Сервер подтвердил пост', postId + ': likes =', serverData.likes)
    },

    // Откат при ошибке
    rollback(postId, error) {
      const saved = pending.get(postId)
      if (!saved) return
      const post = optimistic.find(p => p.id === postId)
      if (post) post.likes = saved.previousLikes
      pending.delete(postId)
      console.log('[ERR] Откат поста', postId + ': likes вернулись к', saved.previousLikes)
      console.log('[ERR] Причина:', error.message)
    },

    getDisplay() { return optimistic },
    getPending() { return Array.from(pending.keys()) },
  }
}

// --- Имитация Server Action ---

async function likePostAction(store, postId) {
  // 1. Оптимистично обновляем UI
  store.optimisticUpdate(postId, post => { post.likes++ })

  // 2. Отправляем реальный запрос на "сервер"
  try {
    const result = await serverLikePost(postId)
    store.confirm(postId, result)
  } catch (err) {
    store.rollback(postId, err)
  }
}

// --- Запуск ---

async function main() {
  const store = createOptimisticUI(serverDB.posts)

  console.log('Начальное состояние:', store.getDisplay().map(p => p.title + ': ' + p.likes + ' лайков'))

  // Лайкаем пост 1 — успех
  console.log('
--- Лайк поста 1 ---')
  const like1 = likePostAction(store, 1)

  // Лайкаем пост 2 — успех
  console.log('--- Лайк поста 2 ---')
  const like2 = likePostAction(store, 2)

  // Попытка лайкнуть несуществующий пост — откат
  console.log('--- Лайк несуществующего поста 99 ---')
  const like3 = likePostAction(store, 99)

  console.log('
Пока ждём сервер, UI показывает:', store.getDisplay().map(p => p.title + ': ' + p.likes))
  console.log('Ожидают подтверждения:', store.getPending())

  await Promise.all([like1, like2, like3])

  console.log('
Итоговое состояние:', store.getDisplay().map(p => p.title + ': ' + p.likes + ' лайков'))
  console.log('Ожидают подтверждения:', store.getPending())
}

main()

Задание

Создай React-форму с имитацией Server Action. Форма отправляет данные, показывает состояние загрузки (pending), и отображает результат (успех или ошибка). Используй хук useActionState для управления состоянием формы.

Подсказка

В useActionState: setIsPending(true) перед action, setState(result) после, setIsPending(false) в конце. SubmitButton: disabled={pending}. В сообщениях: state.error, state.data?.title, state.data?.id. SubmitButton получает pending={isPending}.

Загружаем среду выполнения...
Загружаем AI-помощника...