Skip to content
← К сниппетам
Next.jsReact.js

Хуки React и рецепты Next.js App Router

Одиннадцать сниппетов: собственные хуки React для fetch, debounce, клика вне элемента, media-запросов и сохраняемого состояния, плюс обработчики маршрутов App Router, динамические метаданные, редиректы и аутентификация в middleware.

Две половины одного стека. Хуки — клиентская обвязка, которой React не поставляет; рецепты App Router — серверные соглашения, изменившиеся с уходом Pages Router.

Любой хук с fetch — это гонка, пока вы не отменяете запрос

useFetch, который запускает запрос в useEffect и кладёт состояние в .then(), содержит баг, проявляющийся только у быстрых пользователей: уйти со страницы или сменить запрос до прихода первого ответа — и устаревший ответ придёт вторым и затрёт свежий. Лечение — AbortController в функции очистки эффекта, отменяющий запрос в полёте при смене зависимости. Без него компонент периодически показывает не те данные, а это самый трудновоспроизводимый вид ошибок.

Та же дисциплина очистки проходит через остальные: useDebounce обязан гасить таймер, useOnClickOutside — снимать слушатель, useMediaQuery — отписываться от matchMedia.

Сохраняемое состояние и гидратация

usePersistedState читает localStorage, которого не существует, пока React рендерится на сервере. Чтение при первом рендере даёт рассинхрон гидратации: сервер отрисовал значение по умолчанию, клиент — сохранённое, и React отбрасывает одно из них с предупреждением. Приём, который это снимает, — инициализироваться значением по умолчанию и синхронизироваться в эффекте, ценой одного кадра с дефолтом. Именно поэтому переключатели темы часто используют блокирующий инлайн-скрипт.

App Router: серверные компоненты по умолчанию

Главное практическое изменение в том, что компонент считается серверным, пока не помечен иначе: хуки в нём недоступны, а загрузка данных происходит прямо в теле компонента. У чтения search params, редиректа и генерации метаданных теперь есть серверные API, и использование клиентских аналогов создаёт границу компонента, которая была не нужна.

generateMetadata стоит знать хорошо: выполняется на сервере, может быть асинхронной и может запросить те же данные, что и страница, — Next дедуплицирует запрос. Это делает простым вывод заголовков и описаний из реального содержимого, о чём и написана документация Next.js по метаданным.

Middleware выполняется раньше всего

Аутентификация в middleware означает, что редирект происходит до какой-либо работы по рендерингу, — это и быстрее, и труднее обойти, чем проверка внутри страницы. Ограничение в том, что middleware работает в урезанном рантайме: без Node API и драйверов баз данных, — поэтому он проверяет токен, а всё тяжёлое оставляет защищаемому маршруту.

React.jsReact.js

React context + useReducer для глобального состояния

Замена библиотеки управления состоянием на Context и useReducer — предсказуемый, тестируемый паттерн без внешних зависимостей.

Как это работает: useReducer предоставляет паттерн reducer из Redux (state + action → new state). Context передаёт и state, и dispatch вниз по дереву. Компоненты подписываются только на нужное и диспатчат действия для обновления состояния.

import { createContext, useContext, useReducer, ReactNode } from 'react'

// 1. Типы
type State = {
  user: { id: string; name: string } | null
  theme: 'light' | 'dark'
  sidebarOpen: boolean
}

type Action =
  | { type: 'LOGIN'; payload: { id: string; name: string } }
  | { type: 'LOGOUT' }
  | { type: 'SET_THEME'; payload: 'light' | 'dark' }
  | { type: 'TOGGLE_SIDEBAR' }

// 2. Начальное состояние
const initialState: State = {
  user: null,
  theme: 'light',
  sidebarOpen: false,
}

// 3. Reducer
function appReducer(state: State, action: Action): State {
  switch (action.type) {
    case 'LOGIN':
      return { ...state, user: action.payload }
    case 'LOGOUT':
      return { ...state, user: null }
    case 'SET_THEME':
      return { ...state, theme: action.payload }
    case 'TOGGLE_SIDEBAR':
      return { ...state, sidebarOpen: !state.sidebarOpen }
    default:
      return state
  }
}

// 4. Context
const AppContext = createContext<{
  state: State
  dispatch: React.Dispatch<Action>
} | null>(null)

// 5. Provider
function AppProvider({ children }: { children: ReactNode }) {
  const [state, dispatch] = useReducer(appReducer, initialState)
  return (
    <AppContext.Provider value={{ state, dispatch }}>
      {children}
    </AppContext.Provider>
  )
}

// 6. Типизированный хук
function useApp() {
  const ctx = useContext(AppContext)
  if (!ctx) throw new Error('useApp должен быть внутри AppProvider')
  return ctx
}

// Использование
function ThemeToggle() {
  const { state, dispatch } = useApp()
  return (
    <button onClick={() =>
      dispatch({ type: 'SET_THEME', payload: state.theme === 'light' ? 'dark' : 'light' })
    }>
      {state.theme}
    </button>
  )
}
React.jsReact.js

React useMediaQuery — адаптивный хук

Отслеживание CSS media queries в React-компонентах — определение брейкпоинтов, разный UI для мобильных/планшетов/десктопа и реакция на смену ориентации.

Как это работает: window.matchMedia() возвращает объект MediaQueryList с полем matches и событием change. Хук подписывается на событие и возвращает текущее состояние совпадения.

import { useState, useEffect } from 'react'

function useMediaQuery(query: string): boolean {
  const [matches, setMatches] = useState(
    () => window.matchMedia(query).matches
  )

  useEffect(() => {
    const mql = window.matchMedia(query)
    const handler = (e: MediaQueryListEvent) => setMatches(e.matches)
    mql.addEventListener('change', handler)
    return () => mql.removeEventListener('change', handler)
  }, [query])

  return matches
}

// Использование
function Layout() {
  const isMobile = useMediaQuery('(max-width: 767px)')
  const isTablet = useMediaQuery('(min-width: 768px) and (max-width: 1023px)')
  const isDesktop = useMediaQuery('(min-width: 1024px)')
  const isDarkMode = useMediaQuery('(prefers-color-scheme: dark)')
  const isReducedMotion = useMediaQuery('(prefers-reduced-motion: reduce)')

  return (
    <div>
      {isMobile && <MobileNav />}
      {isDesktop && <Sidebar />}
    </div>
  )
}
// Хук с именованными брейкпоинтами
const breakpoints = {
  sm: '(min-width: 640px)',
  md: '(min-width: 768px)',
  lg: '(min-width: 1024px)',
  xl: '(min-width: 1280px)',
} as const

function useBreakpoint() {
  const sm = useMediaQuery(breakpoints.sm)
  const md = useMediaQuery(breakpoints.md)
  const lg = useMediaQuery(breakpoints.lg)
  const xl = useMediaQuery(breakpoints.xl)

  if (xl) return 'xl'
  if (lg) return 'lg'
  if (md) return 'md'
  if (sm) return 'sm'
  return 'xs'
}
React.jsReact.js

Хук useOnClickOutside

Определяет клик снаружи заданного элемента — стандартный способ закрывать дропдауны, модалки и поповеры.

Как это работает: ref.current.contains(event.target) проверяет, произошёл ли клик ВНУТРИ referenced-элемента (включая любых его потомков) — если да, обработчик пропускается; если клик произошёл где-то ещё в документе, обработчик срабатывает.

import { useEffect } from 'react'

function useOnClickOutside(ref, handler) {
  useEffect(() => {
    function listener(event) {
      if (!ref.current || ref.current.contains(event.target)) return
      handler(event)
    }

    document.addEventListener('mousedown', listener)
    return () => document.removeEventListener('mousedown', listener)
  }, [ref, handler])
}

// Example usage
function Dropdown({ onClose }) {
  const ref = useRef(null)
  useOnClickOutside(ref, onClose)

  return <div ref={ref} className="dropdown">...</div>
}
React.jsReact.js

Хук useDebounce

React-хук, который делает дебаунс быстро меняющегося значения (например, поискового ввода), чтобы эффекты запускались только после того, как пользователь перестал печатать.

Как это работает: в отличие от отдельной функции debounce() (которая откладывает ВЫЗОВ функции), этот хук откладывает ЗНАЧЕНИЕ — каждое нажатие клавиши сразу обновляет value, чтобы поле ввода ощущалось отзывчивым, но debounced (и любой эффект, зависящий от него) обновляется только после того, как пользователь сделал паузу на delay мс.

import { useState, useEffect } from 'react'

function useDebounce(value, delay = 300) {
  const [debounced, setDebounced] = useState(value)

  useEffect(() => {
    const timer = setTimeout(() => setDebounced(value), delay)
    return () => clearTimeout(timer)
  }, [value, delay])

  return debounced
}

// Example usage
function SearchBox() {
  const [query, setQuery] = useState('')
  const debouncedQuery = useDebounce(query, 400)

  useEffect(() => {
    if (debouncedQuery) fetchResults(debouncedQuery)
  }, [debouncedQuery])

  return <input value={query} onChange={(e) => setQuery(e.target.value)} />
}
React.jsReact.js

Хук useFetch

Переиспользуемый хук для загрузки данных с состояниями loading/error, отменяющий запрос при размонтировании компонента или изменении URL.

Как это работает: AbortController отменяет запрос "в полёте", если компонент размонтировался или url изменился до получения ответа — без этого медленный ответ мог бы попытаться вызвать setData у уже несуществующего компонента, либо перезаписать более новые данные устаревшим ответом.

import { useState, useEffect } from 'react'

function useFetch(url) {
  const [data, setData] = useState(null)
  const [loading, setLoading] = useState(true)
  const [error, setError] = useState(null)

  useEffect(() => {
    const controller = new AbortController()

    setLoading(true)
    fetch(url, { signal: controller.signal })
      .then((res) => res.json())
      .then(setData)
      .catch((err) => {
        if (err.name !== 'AbortError') setError(err)
      })
      .finally(() => setLoading(false))

    return () => controller.abort()
  }, [url])

  return { data, loading, error }
}

// Example usage
function UserProfile({ userId }) {
  const { data, loading, error } = useFetch(`/api/users/${userId}`)
  if (loading) return <p>Loading...</p>
  if (error) return <p>Failed to load</p>
  return <p>{data.name}</p>
}
React.jsReact.js

Хук usePersistedState

Замена useState, которая синхронизирует значение с localStorage, чтобы оно сохранялось после перезагрузки страницы.

Как это работает: начальное значение читается из localStorage (с fallback на initialValue), а при каждом обновлении значение записывается обратно в localStorage через эффект.

import { useState, useEffect } from 'react'

function usePersistedState(key, initialValue) {
  const [value, setValue] = useState(() => {
    const stored = window.localStorage.getItem(key)
    return stored !== null ? JSON.parse(stored) : initialValue
  })

  useEffect(() => {
    window.localStorage.setItem(key, JSON.stringify(value))
  }, [key, value])

  return [value, setValue]
}

// Example usage
const [theme, setTheme] = usePersistedState('theme', 'light')
Next.jsNext.js

Создание обработчика API-роута

Способ App Router создать REST-эндпоинт — экспортируйте функцию с именем HTTP-метода вместо одного универсального обработчика.

Как это работает: имя каждой экспортированной функции (GET, POST, PUT, DELETE...) напрямую соответствует HTTP-методу — Next.js автоматически возвращает 405 для любого метода, который вы не экспортировали, ручная маршрутизация не нужна.

// app/api/users/[id]/route.ts
import { NextResponse } from 'next/server'

export async function GET(request: Request, { params }: { params: { id: string } }) {
  const user = await getUser(params.id)
  if (!user) {
    return NextResponse.json({ error: 'Not found' }, { status: 404 })
  }
  return NextResponse.json(user)
}

export async function POST(request: Request) {
  const body = await request.json()
  const created = await createUser(body)
  return NextResponse.json(created, { status: 201 })
}
Next.jsNext.js

Генерация динамических метаданных страницы

Задаёт <title> и meta-теги страницы на основе загруженных данных — замена next/head в App Router.

Как это работает: generateMetadata выполняется на сервере до рендера страницы, получает те же params, что и компонент страницы, и может делать await данных — поэтому <title> и OG-теги корректны даже для динамического роута, без какого-либо клиентского JavaScript.

// app/products/[id]/page.tsx
export async function generateMetadata({ params }) {
  const product = await getProduct(params.id)

  return {
    title: product.name,
    description: product.shortDescription,
    openGraph: {
      title: product.name,
      images: [product.image],
    },
  }
}

export default async function ProductPage({ params }) {
  const product = await getProduct(params.id)
  return <h1>{product.name}</h1>
}
Next.jsNext.js

Чтение search params в серверном компоненте

Доступ к текущей query-строке URL внутри Server Component в App Router без клиентского JavaScript.

Как это работает: App Router передаёт searchParams как проп в любой Server Component уровня страницы — не нужен ни хук useSearchParams, ни директива 'use client'.

// app/products/page.tsx
export default function ProductsPage({
  searchParams,
}: {
  searchParams: { [key: string]: string | string[] | undefined }
}) {
  const sort = searchParams.sort ?? 'newest'

  return <p>Sorted by: {sort}</p>
}
Next.jsNext.js

Редирект из Server Component или Route Handler

Встроенный в App Router способ отправить пользователя на другую страницу — без ручного создания Response.

Как это работает: redirect() бросает специальный внутренний сигнал, который Next.js перехватывает, чтобы выполнить реальный HTTP-редирект — из-за этого код после redirect() никогда не выполняется, поэтому не нужны ни ветка else, ни ранний return.

import { redirect } from 'next/navigation'

export default async function DashboardPage() {
  const session = await getSession()

  if (!session) {
    redirect('/login')
  }

  return <div>Welcome, {session.user.name}</div>
}
Next.jsNext.js

Next.js App Router middleware — аутентификация и редиректы

Защита маршрутов через Next.js middleware — проверка auth-токенов, редирект неавторизованных пользователей, i18n-роутинг.

Как это работает: middleware.ts выполняется перед каждым запросом на соответствующих маршрутах. Он читает cookie сессии, декодирует JWT и либо редиректит неавторизованных пользователей на страницу входа, либо перенаправляет запрос для i18n-роутинга.

// middleware.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'

const publicRoutes = ['/login', '/register', '/blog', '/snippets']

export function middleware(request: NextRequest) {
  const { pathname } = request.nextUrl

  // I18n: редирект с корня на предпочитаемый язык
  if (pathname === '/') {
    const locale = request.cookies.get('NEXT_LOCALE')?.value || 'en'
    return NextResponse.redirect(new URL(`/${locale}`, request.url))
  }

  // Пропускаем публичные маршруты и статику
  if (publicRoutes.some(r => pathname.startsWith(r))) {
    return NextResponse.next()
  }

  // Проверка сессии
  const session = request.cookies.get('session')?.value
  if (!session) {
    const loginUrl = new URL('/login', request.url)
    loginUrl.searchParams.set('redirect', pathname)
    return NextResponse.redirect(loginUrl)
  }

  return NextResponse.next()
}

export const config = {
  matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
}
# Файл создаётся в корне Next.js проекта:
# app/
#   middleware.ts
#   (route groups...)
// CONNECT

Контактная информация

Оставьте заявку или напишите напрямую в телеграм. Я отвечу в течение 24 часов.

Укажите email или Telegram, чтобы я мог вам ответить.