React hooks and Next.js App Router recipes
Eleven snippets: custom React hooks for fetch, debounce, click-outside, media queries and persisted state, plus Next.js App Router route handlers, dynamic metadata, redirects and middleware authentication.
Two halves of the same stack. The hooks are the client-side plumbing React does not ship; the App Router recipes are the server-side conventions that changed when the Pages Router gave way.
Every fetch hook is a race condition until you cancel
A useFetch that starts a request in useEffect and sets state in .then() has a bug that only shows up when the user is quick: navigate away or change the query before the first response lands, and the stale response arrives second and overwrites the fresh one. The fix is an AbortController in the effect's cleanup, which cancels the in-flight request when the dependency changes. Without it the component displays the wrong data intermittently, which is the hardest kind of bug to reproduce.
The same cleanup discipline runs through the rest: useDebounce must clear its timer, useOnClickOutside must remove its listener, useMediaQuery must detach from matchMedia.
Persisted state and hydration
usePersistedState reads localStorage, which does not exist while React renders on the server. Reading it during the initial render produces a hydration mismatch: the server renders the default, the client renders the stored value, and React discards one of them with a warning. Initialising with the default and syncing in an effect is the pattern that avoids it — at the cost of one frame showing the default, which is why theme toggles often use a blocking inline script instead.
App Router: server components are the default
The largest practical change is that components are server components unless marked otherwise, which means hooks are unavailable in them and data fetching happens directly in the component body. Reading search params, redirecting, and generating metadata all have server-side APIs now, and using the client-side equivalents forces a component boundary you did not need.
generateMetadata is the one worth knowing well: it runs on the server, can be async, and can fetch the same data the page fetches — Next deduplicates the request. That makes per-page titles and descriptions derived from real content straightforward, which is exactly what the Next.js metadata documentation covers.
Middleware runs before everything
Authentication in middleware means the redirect happens before any rendering work, which is both faster and harder to bypass than a check inside a page. The constraint is that middleware runs in a limited runtime — no Node APIs, no database drivers — so it verifies a token and defers anything heavier to the route it protects.
React context + useReducer for global state
Replace a state management library with Context and useReducer — a predictable, testable global state pattern without external dependencies.
How it works: useReducer provides the reducer pattern from Redux (state + action → new state). Context passes both state and dispatch down the tree. Components subscribe to only what they need, and dispatch actions to update state.
import { createContext, useContext, useReducer, ReactNode } from 'react'
// 1. Types
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. Initial state
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. Typed hook
function useApp() {
const ctx = useContext(AppContext)
if (!ctx) throw new Error('useApp must be inside AppProvider')
return ctx
}
// Usage
function ThemeToggle() {
const { state, dispatch } = useApp()
return (
<button onClick={() =>
dispatch({ type: 'SET_THEME', payload: state.theme === 'light' ? 'dark' : 'light' })
}>
{state.theme}
</button>
)
}
React useMediaQuery — responsive hook
Track CSS media queries in React components — detect breakpoints, render different UI for mobile/tablet/desktop, and react to orientation changes.
How it works: window.matchMedia() returns a MediaQueryList object with a matches boolean and fires a change event when the query result changes. The hook subscribes to the event and returns the current match state.
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
}
// Usage
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>
)
}
// Named breakpoint hook
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'
}
useOnClickOutside hook
Detects a click outside a referenced element — the standard way to close dropdowns, modals, and popovers.
How it works: ref.current.contains(event.target) checks whether the click happened INSIDE the referenced element (including on any of its children) — if it did, the handler is skipped; if the click landed anywhere else in the document, the handler fires.
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>
}
useDebounce hook
A React hook that debounces a fast-changing value (like search input) so effects only run once the user stops typing.
How it works: unlike a standalone debounce() function (which delays a function CALL), this delays a VALUE — every keystroke updates value immediately so the input feels responsive, but debounced (and therefore any effect that depends on it) only updates after the user pauses for delay ms.
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)} />
}
useFetch hook
A reusable hook for fetching data with loading/error state, handling request cancellation when the component unmounts or the URL changes.
How it works: the AbortController cancels the in-flight request if the component unmounts or url changes before the response arrives — without it, a slow response could try to setData on a component that's already gone, or overwrite newer data with a stale response.
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>
}
usePersistedState hook
A useState drop-in that syncs its value to localStorage so it survives page reloads.
How it works: reads the initial value from localStorage (falling back to initialValue), then writes back to localStorage on every update via an effect.
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')
Create an API route handler
The App Router way to build a REST endpoint — export a function named after the HTTP method instead of a single catch-all handler.
How it works: each exported function name (GET, POST, PUT, DELETE, ...) maps directly to the matching HTTP method — Next.js automatically returns 405 for any method you don't export, no manual routing needed.
// 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 })
}
Generate dynamic page metadata
Sets a page's <title> and meta tags based on fetched data — the App Router replacement for next/head.
How it works: generateMetadata runs on the server before the page renders, receives the same params the page component gets, and can await data — so the <title> and OG tags are correct even for a dynamic route, without any client-side 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>
}
Read search params in a Server Component
Access the current URL's query string inside an App Router Server Component without client-side JavaScript.
How it works: the App Router passes searchParams as a prop to any page-level Server Component — no useSearchParams hook or 'use client' needed.
// 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>
}
Redirect from a Server Component or Route Handler
The App Router's built-in way to send a user to another page — no need to construct a Response manually.
How it works: redirect() throws a special internal signal that Next.js catches to issue the actual HTTP redirect — because of that, code placed after redirect() never runs, so there's no need for an else branch or early 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.js App Router middleware — authentication and redirects
Protect routes with Next.js middleware — check auth tokens, redirect unauthenticated users, and apply i18n routing.
How it works: middleware.ts runs before every request on matched routes. It reads the session cookie, decodes the JWT, and either redirects unauthenticated users to login or rewrites the request for i18n routing.
// 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: redirect root to preferred locale
if (pathname === '/') {
const locale = request.cookies.get('NEXT_LOCALE')?.value || 'en'
return NextResponse.redirect(new URL(`/${locale}`, request.url))
}
// Skip auth check for public routes and static files
if (publicRoutes.some(r => pathname.startsWith(r))) {
return NextResponse.next()
}
// Check session
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).*)'],
}
# Create the file at the root of your Next.js project:
# app/
# middleware.ts
# (route groups...)
More snippet collections
Contact Information
Submit a request or write to me directly on Telegram. I'll reply within 24 hours.