From 7dc59fb3c4f6849b2293ec48d081d556a7f9186c Mon Sep 17 00:00:00 2001 From: ollyhearn Date: Wed, 3 Jun 2026 10:41:53 +0300 Subject: [PATCH] feat: auth & admin --- .../vercel-react-best-practices/AGENTS.md | 1532 ++++++++--------- .../vercel-react-best-practices/README.md | 10 +- .../vercel-react-best-practices/SKILL.md | 24 +- .../rules/_template.md | 4 +- .../rules/advanced-effect-event-deps.md | 46 +- .../rules/advanced-event-handler-refs.md | 30 +- .../rules/advanced-init-once.md | 18 +- .../rules/advanced-use-latest.md | 18 +- .../rules/async-api-routes.md | 20 +- .../async-cheap-condition-before-await.md | 4 +- .../rules/async-defer-await.md | 52 +- .../rules/async-dependencies.md | 31 +- .../rules/async-parallel.md | 10 +- .../rules/async-suspense-boundaries.md | 26 +- .../rules/bundle-analyzable-paths.md | 13 +- .../rules/bundle-barrel-imports.md | 16 +- .../rules/bundle-conditional.md | 20 +- .../rules/bundle-defer-third-party.md | 14 +- .../rules/bundle-dynamic-imports.md | 14 +- .../rules/bundle-preload.md | 22 +- .../rules/client-event-listeners.md | 46 +- .../rules/client-localstorage-schema.md | 36 +- .../rules/client-passive-event-listeners.md | 40 +- .../rules/client-swr-dedup.md | 22 +- .../rules/js-batch-dom-css.md | 75 +- .../rules/js-cache-function-results.md | 16 +- .../rules/js-cache-property-access.md | 8 +- .../rules/js-cache-storage.md | 28 +- .../rules/js-combine-iterations.md | 18 +- .../rules/js-early-exit.md | 24 +- .../rules/js-flatmap-filter.md | 29 +- .../rules/js-hoist-regexp.md | 6 +- .../rules/js-index-maps.md | 14 +- .../rules/js-length-check-first.md | 13 +- .../rules/js-min-max-loop.md | 52 +- .../rules/js-request-idle-callback.md | 45 +- .../rules/js-tosorted-immutable.md | 2 +- .../rules/rendering-activity.md | 4 +- .../rules/rendering-animate-svg-wrapper.md | 17 +- .../rules/rendering-conditional-render.md | 12 +- .../rules/rendering-content-visibility.md | 4 +- .../rules/rendering-hoist-jsx.md | 18 +- .../rules/rendering-hydration-no-flicker.md | 34 +- .../rendering-hydration-suppress-warning.md | 10 +- .../rules/rendering-resource-hints.md | 44 +- .../rules/rendering-script-defer-async.md | 13 +- .../rules/rendering-usetransition-loading.md | 42 +- .../rules/rerender-defer-reads.md | 20 +- .../rules/rerender-dependencies.md | 18 +- .../rules/rerender-derived-state-no-effect.md | 20 +- .../rules/rerender-derived-state.md | 10 +- .../rules/rerender-functional-setstate.md | 41 +- .../rules/rerender-lazy-state-init.md | 34 +- .../rules/rerender-memo-with-default-value.md | 2 - .../rules/rerender-memo.md | 20 +- .../rules/rerender-move-effect-to-event.md | 20 +- .../rules/rerender-no-inline-components.md | 13 +- .../rerender-simple-expression-in-memo.md | 10 +- .../rules/rerender-split-combined-hooks.md | 34 +- .../rules/rerender-transitions.md | 24 +- .../rules/rerender-use-deferred-value.md | 24 +- .../rerender-use-ref-transient-values.md | 32 +- .../rules/server-after-nonblocking.md | 43 +- .../rules/server-auth-actions.md | 62 +- .../rules/server-cache-lru.md | 16 +- .../rules/server-cache-react.md | 34 +- .../rules/server-dedup-props.md | 6 +- .../rules/server-hoist-static-io.md | 22 +- .../rules/server-no-shared-module-state.md | 14 +- .../rules/server-parallel-fetching.md | 30 +- .../rules/server-parallel-nested-fetching.md | 12 +- .../rules/server-serialization.md | 16 +- package-lock.json | 1 + package.json | 1 + src/api/baseQuery.ts | 61 +- src/api/endpoints/admin.ts | 28 +- src/api/endpoints/auth.ts | 5 +- src/api/endpoints/downloads.ts | 20 +- src/api/endpoints/library.ts | 56 +- src/api/endpoints/likes.ts | 5 +- src/api/endpoints/playlists.ts | 45 +- src/api/endpoints/storage.ts | 11 +- src/api/endpoints/streaming.ts | 3 +- src/api/index.ts | 11 +- src/components/common/ArtTile.tsx | 76 + src/components/common/EmptyState.tsx | 30 +- src/components/common/ErrorState.tsx | 12 +- src/components/common/Icon.tsx | 101 ++ src/components/layout/AppShell.tsx | 21 +- src/components/layout/Sidebar.tsx | 212 ++- src/components/player/PersistentPlayer.tsx | 181 +- src/components/player/QueuePanel.tsx | 201 ++- src/components/track/AvailabilityBadge.tsx | 31 +- src/components/track/TrackContextMenu.tsx | 65 +- src/components/track/TrackRow.tsx | 77 +- src/config/env.ts | 3 +- src/config/instances.ts | 186 ++ src/config/runtime-config.ts | 23 +- src/features/admin/AdminPage.tsx | 8 +- src/features/album-detail/AlbumDetailPage.tsx | 157 +- src/features/connect/ConnectPage.tsx | 208 ++- .../DownloadsManagerPage.tsx | 8 +- src/features/home/HomePage.tsx | 304 +++- src/features/library/LibraryPage.tsx | 279 ++- .../playlist-detail/PlaylistDetailPage.tsx | 147 +- .../search-download/SearchDownloadPage.tsx | 8 +- src/features/settings/SettingsPage.tsx | 8 +- src/features/storage/StoragePage.tsx | 8 +- src/hooks/useAppDispatch.ts | 3 +- src/hooks/useAudioPlayer.ts | 39 +- src/hooks/useConnectionStatus.ts | 13 +- src/hooks/usePermissions.ts | 17 +- src/index.tsx | 1 + src/lib/format.ts | 6 +- src/routes/index.tsx | 79 +- src/store/slices/auth.ts | 25 +- src/store/slices/player.ts | 69 +- src/store/slices/queue.ts | 79 +- src/store/slices/ui.ts | 28 +- src/styles/shell.css | 709 ++++++++ 120 files changed, 4683 insertions(+), 2159 deletions(-) create mode 100644 src/components/common/ArtTile.tsx create mode 100644 src/components/common/Icon.tsx create mode 100644 src/config/instances.ts create mode 100644 src/styles/shell.css diff --git a/.agents/skills/vercel-react-best-practices/AGENTS.md b/.agents/skills/vercel-react-best-practices/AGENTS.md index 4e340a5..26c329d 100644 --- a/.agents/skills/vercel-react-best-practices/AGENTS.md +++ b/.agents/skills/vercel-react-best-practices/AGENTS.md @@ -118,7 +118,7 @@ This is a specialization of [Defer Await Until Needed](./async-defer-await.md) f **Incorrect:** ```typescript -const someFlag = await getFlag() +const someFlag = await getFlag(); if (someFlag && someCondition) { // ... @@ -129,7 +129,7 @@ if (someFlag && someCondition) { ```typescript if (someCondition) { - const someFlag = await getFlag() + const someFlag = await getFlag(); if (someFlag) { // ... } @@ -150,15 +150,15 @@ Move `await` operations into the branches where they're actually used to avoid b ```typescript async function handleRequest(userId: string, skipProcessing: boolean) { - const userData = await fetchUserData(userId) - + const userData = await fetchUserData(userId); + if (skipProcessing) { // Returns immediately but still waited for userData - return { skipped: true } + return { skipped: true }; } - + // Only this branch uses userData - return processUserData(userData) + return processUserData(userData); } ``` @@ -168,12 +168,12 @@ async function handleRequest(userId: string, skipProcessing: boolean) { async function handleRequest(userId: string, skipProcessing: boolean) { if (skipProcessing) { // Returns immediately without waiting - return { skipped: true } + return { skipped: true }; } - + // Fetch only when needed - const userData = await fetchUserData(userId) - return processUserData(userData) + const userData = await fetchUserData(userId); + return processUserData(userData); } ``` @@ -182,35 +182,35 @@ async function handleRequest(userId: string, skipProcessing: boolean) { ```typescript // Incorrect: always fetches permissions async function updateResource(resourceId: string, userId: string) { - const permissions = await fetchPermissions(userId) - const resource = await getResource(resourceId) - + const permissions = await fetchPermissions(userId); + const resource = await getResource(resourceId); + if (!resource) { - return { error: 'Not found' } + return { error: 'Not found' }; } - + if (!permissions.canEdit) { - return { error: 'Forbidden' } + return { error: 'Forbidden' }; } - - return await updateResourceData(resource, permissions) + + return await updateResourceData(resource, permissions); } // Correct: fetches only when needed async function updateResource(resourceId: string, userId: string) { - const resource = await getResource(resourceId) - + const resource = await getResource(resourceId); + if (!resource) { - return { error: 'Not found' } + return { error: 'Not found' }; } - - const permissions = await fetchPermissions(userId) - + + const permissions = await fetchPermissions(userId); + if (!permissions.canEdit) { - return { error: 'Forbidden' } + return { error: 'Forbidden' }; } - - return await updateResourceData(resource, permissions) + + return await updateResourceData(resource, permissions); } ``` @@ -227,38 +227,39 @@ For operations with partial dependencies, use `better-all` to maximize paralleli **Incorrect: profile waits for config unnecessarily** ```typescript -const [user, config] = await Promise.all([ - fetchUser(), - fetchConfig() -]) -const profile = await fetchProfile(user.id) +const [user, config] = await Promise.all([fetchUser(), fetchConfig()]); +const profile = await fetchProfile(user.id); ``` **Correct: config and profile run in parallel** ```typescript -import { all } from 'better-all' +import { all } from 'better-all'; const { user, config, profile } = await all({ - async user() { return fetchUser() }, - async config() { return fetchConfig() }, + async user() { + return fetchUser(); + }, + async config() { + return fetchConfig(); + }, async profile() { - return fetchProfile((await this.$.user).id) - } -}) + return fetchProfile((await this.$.user).id); + }, +}); ``` **Alternative without extra dependencies:** ```typescript -const userPromise = fetchUser() -const profilePromise = userPromise.then(user => fetchProfile(user.id)) +const userPromise = fetchUser(); +const profilePromise = userPromise.then((user) => fetchProfile(user.id)); const [user, config, profile] = await Promise.all([ userPromise, fetchConfig(), - profilePromise -]) + profilePromise, +]); ``` We can also create all the promises first, and do `Promise.all()` at the end. @@ -275,10 +276,10 @@ In API routes and Server Actions, start independent operations immediately, even ```typescript export async function GET(request: Request) { - const session = await auth() - const config = await fetchConfig() - const data = await fetchData(session.user.id) - return Response.json({ data, config }) + const session = await auth(); + const config = await fetchConfig(); + const data = await fetchData(session.user.id); + return Response.json({ data, config }); } ``` @@ -286,14 +287,14 @@ export async function GET(request: Request) { ```typescript export async function GET(request: Request) { - const sessionPromise = auth() - const configPromise = fetchConfig() - const session = await sessionPromise + const sessionPromise = auth(); + const configPromise = fetchConfig(); + const session = await sessionPromise; const [config, data] = await Promise.all([ configPromise, - fetchData(session.user.id) - ]) - return Response.json({ data, config }) + fetchData(session.user.id), + ]); + return Response.json({ data, config }); } ``` @@ -308,9 +309,9 @@ When async operations have no interdependencies, execute them concurrently using **Incorrect: sequential execution, 3 round trips** ```typescript -const user = await fetchUser() -const posts = await fetchPosts() -const comments = await fetchComments() +const user = await fetchUser(); +const posts = await fetchPosts(); +const comments = await fetchComments(); ``` **Correct: parallel execution, 1 round trip** @@ -319,8 +320,8 @@ const comments = await fetchComments() const [user, posts, comments] = await Promise.all([ fetchUser(), fetchPosts(), - fetchComments() -]) + fetchComments(), +]); ``` ### 1.6 Strategic Suspense Boundaries @@ -333,8 +334,8 @@ Instead of awaiting data in async components before returning JSX, use Suspense ```tsx async function Page() { - const data = await fetchData() // Blocks entire page - + const data = await fetchData(); // Blocks entire page + return (
Sidebar
@@ -344,7 +345,7 @@ async function Page() {
Footer
- ) + ); } ``` @@ -365,12 +366,12 @@ function Page() {
Footer
- ) + ); } async function DataDisplay() { - const data = await fetchData() // Only blocks this component - return
{data.content}
+ const data = await fetchData(); // Only blocks this component + return
{data.content}
; } ``` @@ -381,8 +382,8 @@ Sidebar, Header, and Footer render immediately. Only DataDisplay waits for data. ```tsx function Page() { // Start fetch immediately, but don't await - const dataPromise = fetchData() - + const dataPromise = fetchData(); + return (
Sidebar
@@ -393,17 +394,17 @@ function Page() {
Footer
- ) + ); } function DataDisplay({ dataPromise }: { dataPromise: Promise }) { - const data = use(dataPromise) // Unwraps the promise - return
{data.content}
+ const data = use(dataPromise); // Unwraps the promise + return
{data.content}
; } function DataSummary({ dataPromise }: { dataPromise: Promise }) { - const data = use(dataPromise) // Reuses the same promise - return
{data.summary}
+ const data = use(dataPromise); // Reuses the same promise + return
{data.summary}
; } ``` @@ -442,11 +443,11 @@ Popular icon and component libraries can have **up to 10,000 re-exports** in the **Incorrect: imports entire library** ```tsx -import { Check, X, Menu } from 'lucide-react' +import { Check, X, Menu } from 'lucide-react'; // Loads 1,583 modules, takes ~2.8s extra in dev // Runtime cost: 200-800ms on every cold start -import { Button, TextField } from '@mui/material' +import { Button, TextField } from '@mui/material'; // Loads 2,225 modules, takes ~4.2s extra in dev ``` @@ -454,7 +455,7 @@ import { Button, TextField } from '@mui/material' ```tsx // Keep the standard imports - Next.js transforms them to direct imports -import { Check, X, Menu } from 'lucide-react' +import { Check, X, Menu } from 'lucide-react'; // Full TypeScript support, no manual path wrangling ``` @@ -463,8 +464,8 @@ This is the recommended approach because it preserves TypeScript type safety and **Correct - Direct imports (non-Next.js projects):** ```tsx -import Button from '@mui/material/Button' -import TextField from '@mui/material/TextField' +import Button from '@mui/material/Button'; +import TextField from '@mui/material/TextField'; // Loads only what you use ``` @@ -485,19 +486,25 @@ Load large data or modules only when a feature is activated. **Example: lazy-load animation frames** ```tsx -function AnimationPlayer({ enabled, setEnabled }: { enabled: boolean; setEnabled: React.Dispatch> }) { - const [frames, setFrames] = useState(null) +function AnimationPlayer({ + enabled, + setEnabled, +}: { + enabled: boolean; + setEnabled: React.Dispatch>; +}) { + const [frames, setFrames] = useState(null); useEffect(() => { if (enabled && !frames && typeof window !== 'undefined') { import('./animation-frames.js') - .then(mod => setFrames(mod.frames)) - .catch(() => setEnabled(false)) + .then((mod) => setFrames(mod.frames)) + .catch(() => setEnabled(false)); } - }, [enabled, frames, setEnabled]) + }, [enabled, frames, setEnabled]); - if (!frames) return - return + if (!frames) return ; + return ; } ``` @@ -512,7 +519,7 @@ Analytics, logging, and error tracking don't block user interaction. Load them a **Incorrect: blocks initial bundle** ```tsx -import { Analytics } from '@vercel/analytics/react' +import { Analytics } from '@vercel/analytics/react'; export default function RootLayout({ children }) { return ( @@ -522,19 +529,19 @@ export default function RootLayout({ children }) { - ) + ); } ``` **Correct: loads after hydration** ```tsx -import dynamic from 'next/dynamic' +import dynamic from 'next/dynamic'; const Analytics = dynamic( - () => import('@vercel/analytics/react').then(m => m.Analytics), - { ssr: false } -) + () => import('@vercel/analytics/react').then((m) => m.Analytics), + { ssr: false }, +); export default function RootLayout({ children }) { return ( @@ -544,7 +551,7 @@ export default function RootLayout({ children }) { - ) + ); } ``` @@ -557,25 +564,25 @@ Use `next/dynamic` to lazy-load large components not needed on initial render. **Incorrect: Monaco bundles with main chunk ~300KB** ```tsx -import { MonacoEditor } from './monaco-editor' +import { MonacoEditor } from './monaco-editor'; function CodePanel({ code }: { code: string }) { - return + return ; } ``` **Correct: Monaco loads on demand** ```tsx -import dynamic from 'next/dynamic' +import dynamic from 'next/dynamic'; const MonacoEditor = dynamic( - () => import('./monaco-editor').then(m => m.MonacoEditor), - { ssr: false } -) + () => import('./monaco-editor').then((m) => m.MonacoEditor), + { ssr: false }, +); function CodePanel({ code }: { code: string }) { - return + return ; } ``` @@ -603,9 +610,9 @@ When analysis becomes too broad, the cost is real: const PAGE_MODULES = { home: './pages/home', settings: './pages/settings', -} as const +} as const; -const Page = await import(PAGE_MODULES[pageName]) +const Page = await import(PAGE_MODULES[pageName]); ``` **Correct: use an explicit map of allowed modules** @@ -614,15 +621,15 @@ const Page = await import(PAGE_MODULES[pageName]) const PAGE_MODULES = { home: () => import('./pages/home'), settings: () => import('./pages/settings'), -} as const +} as const; -const Page = await PAGE_MODULES[pageName]() +const Page = await PAGE_MODULES[pageName](); ``` **Incorrect: a 2-value enum still hides the final path from static analysis** ```ts -const baseDir = path.join(process.cwd(), 'content/' + contentKind) +const baseDir = path.join(process.cwd(), 'content/' + contentKind); ``` **Correct: make each final path literal at the callsite** @@ -631,7 +638,7 @@ const baseDir = path.join(process.cwd(), 'content/' + contentKind) const baseDir = kind === ContentKind.Blog ? path.join(process.cwd(), 'content/blog') - : path.join(process.cwd(), 'content/docs') + : path.join(process.cwd(), 'content/docs'); ``` In Next.js server code, this matters for output file tracing too. `path.join(process.cwd(), someVar)` can widen the traced file set because Next.js statically analyze `import`, `require`, and `fs` usage. @@ -650,19 +657,15 @@ Preload heavy bundles before they're needed to reduce perceived latency. function EditorButton({ onClick }: { onClick: () => void }) { const preload = () => { if (typeof window !== 'undefined') { - void import('./monaco-editor') + void import('./monaco-editor'); } - } + }; return ( - - ) + ); } ``` @@ -672,13 +675,13 @@ function EditorButton({ onClick }: { onClick: () => void }) { function FlagsProvider({ children, flags }: Props) { useEffect(() => { if (flags.editorEnabled && typeof window !== 'undefined') { - void import('./monaco-editor').then(mod => mod.init()) + void import('./monaco-editor').then((mod) => mod.init()); } - }, [flags.editorEnabled]) + }, [flags.editorEnabled]); - return - {children} - + return ( + {children} + ); } ``` @@ -703,80 +706,80 @@ Next.js documentation explicitly states: "Treat Server Actions with the same sec **Incorrect: no authentication check** ```typescript -'use server' +'use server'; export async function deleteUser(userId: string) { // Anyone can call this! No auth check - await db.user.delete({ where: { id: userId } }) - return { success: true } + await db.user.delete({ where: { id: userId } }); + return { success: true }; } ``` **Correct: authentication inside the action** ```typescript -'use server' +'use server'; -import { verifySession } from '@/lib/auth' -import { unauthorized } from '@/lib/errors' +import { verifySession } from '@/lib/auth'; +import { unauthorized } from '@/lib/errors'; export async function deleteUser(userId: string) { // Always check auth inside the action - const session = await verifySession() - + const session = await verifySession(); + if (!session) { - throw unauthorized('Must be logged in') + throw unauthorized('Must be logged in'); } - + // Check authorization too if (session.user.role !== 'admin' && session.user.id !== userId) { - throw unauthorized('Cannot delete other users') + throw unauthorized('Cannot delete other users'); } - - await db.user.delete({ where: { id: userId } }) - return { success: true } + + await db.user.delete({ where: { id: userId } }); + return { success: true }; } ``` **With input validation:** ```typescript -'use server' +'use server'; -import { verifySession } from '@/lib/auth' -import { z } from 'zod' +import { verifySession } from '@/lib/auth'; +import { z } from 'zod'; const updateProfileSchema = z.object({ userId: z.string().uuid(), name: z.string().min(1).max(100), - email: z.string().email() -}) + email: z.string().email(), +}); export async function updateProfile(data: unknown) { // Validate input first - const validated = updateProfileSchema.parse(data) - + const validated = updateProfileSchema.parse(data); + // Then authenticate - const session = await verifySession() + const session = await verifySession(); if (!session) { - throw new Error('Unauthorized') + throw new Error('Unauthorized'); } - + // Then authorize if (session.user.id !== validated.userId) { - throw new Error('Can only update own profile') + throw new Error('Can only update own profile'); } - + // Finally perform the mutation await db.user.update({ where: { id: validated.userId }, data: { name: validated.name, - email: validated.email - } - }) - - return { success: true } + email: validated.email, + }, + }); + + return { success: true }; } ``` @@ -799,11 +802,11 @@ RSC→client serialization deduplicates by object reference, not value. Same ref ```tsx // RSC: send once - +; // Client: transform there -'use client' -const sorted = useMemo(() => [...usernames].sort(), [usernames]) +('use client'); +const sorted = useMemo(() => [...usernames].sort(), [usernames]); ``` **Nested deduplication behavior:** @@ -854,15 +857,15 @@ Treat module scope on the server as process-wide shared memory, not request-loca **Incorrect: request data leaks across concurrent renders** ```tsx -let currentUser: User | null = null +let currentUser: User | null = null; export default async function Page() { - currentUser = await auth() - return + currentUser = await auth(); + return ; } async function Dashboard() { - return
{currentUser?.name}
+ return
{currentUser?.name}
; } ``` @@ -872,12 +875,12 @@ If two requests overlap, request A can set `currentUser`, then request B overwri ```tsx export default async function Page() { - const user = await auth() - return + const user = await auth(); + return ; } function Dashboard({ user }: { user: User | null }) { - return
{user?.name}
+ return
{user?.name}
; } ``` @@ -900,20 +903,20 @@ For static assets and config, see [Hoist Static I/O to Module Level](./server-ho **Implementation:** ```typescript -import { LRUCache } from 'lru-cache' +import { LRUCache } from 'lru-cache'; const cache = new LRUCache({ max: 1000, - ttl: 5 * 60 * 1000 // 5 minutes -}) + ttl: 5 * 60 * 1000, // 5 minutes +}); export async function getUser(id: string) { - const cached = cache.get(id) - if (cached) return cached + const cached = cache.get(id); + if (cached) return cached; - const user = await db.user.findUnique({ where: { id } }) - cache.set(id, user) - return user + const user = await db.user.findUnique({ where: { id } }); + cache.set(id, user); + return user; } // Request 1: DB query, result cached @@ -1020,35 +1023,31 @@ export async function GET(request: Request) { **Incorrect: reads config on every call** ```typescript -import fs from 'node:fs/promises' +import fs from 'node:fs/promises'; export async function processRequest(data: Data) { - const config = JSON.parse( - await fs.readFile('./config.json', 'utf-8') - ) - const template = await fs.readFile('./template.html', 'utf-8') + const config = JSON.parse(await fs.readFile('./config.json', 'utf-8')); + const template = await fs.readFile('./template.html', 'utf-8'); - return render(template, data, config) + return render(template, data, config); } ``` **Correct: hoists config and template to module level** ```typescript -import fs from 'node:fs/promises' +import fs from 'node:fs/promises'; -const configPromise = fs - .readFile('./config.json', 'utf-8') - .then(JSON.parse) -const templatePromise = fs.readFile('./template.html', 'utf-8') +const configPromise = fs.readFile('./config.json', 'utf-8').then(JSON.parse); +const templatePromise = fs.readFile('./template.html', 'utf-8'); export async function processRequest(data: Data) { const [config, template] = await Promise.all([ configPromise, templatePromise, - ]) + ]); - return render(template, data, config) + return render(template, data, config); } ``` @@ -1088,13 +1087,13 @@ The React Server/Client boundary serializes all object properties into strings a ```tsx async function Page() { - const user = await fetchUser() // 50 fields - return + const user = await fetchUser(); // 50 fields + return ; } -'use client' +('use client'); function Profile({ user }: { user: User }) { - return
{user.name}
// uses 1 field + return
{user.name}
; // uses 1 field } ``` @@ -1102,13 +1101,13 @@ function Profile({ user }: { user: User }) { ```tsx async function Page() { - const user = await fetchUser() - return + const user = await fetchUser(); + return ; } -'use client' +('use client'); function Profile({ name }: { name: string }) { - return
{name}
+ return
{name}
; } ``` @@ -1122,18 +1121,18 @@ React Server Components execute sequentially within a tree. Restructure with com ```tsx export default async function Page() { - const header = await fetchHeader() + const header = await fetchHeader(); return (
{header}
- ) + ); } async function Sidebar() { - const items = await fetchSidebarItems() - return + const items = await fetchSidebarItems(); + return ; } ``` @@ -1141,13 +1140,13 @@ async function Sidebar() { ```tsx async function Header() { - const data = await fetchHeader() - return
{data}
+ const data = await fetchHeader(); + return
{data}
; } async function Sidebar() { - const items = await fetchSidebarItems() - return + const items = await fetchSidebarItems(); + return ; } export default function Page() { @@ -1156,7 +1155,7 @@ export default function Page() {
- ) + ); } ``` @@ -1164,13 +1163,13 @@ export default function Page() { ```tsx async function Header() { - const data = await fetchHeader() - return
{data}
+ const data = await fetchHeader(); + return
{data}
; } async function Sidebar() { - const items = await fetchSidebarItems() - return + const items = await fetchSidebarItems(); + return ; } function Layout({ children }: { children: ReactNode }) { @@ -1179,7 +1178,7 @@ function Layout({ children }: { children: ReactNode }) {
{children} - ) + ); } export default function Page() { @@ -1187,7 +1186,7 @@ export default function Page() { - ) + ); } ``` @@ -1200,13 +1199,11 @@ When fetching nested data in parallel, chain dependent fetches within each item' **Incorrect: a single slow item blocks all nested fetches** ```tsx -const chats = await Promise.all( - chatIds.map(id => getChat(id)) -) +const chats = await Promise.all(chatIds.map((id) => getChat(id))); const chatAuthors = await Promise.all( - chats.map(chat => getUser(chat.author)) -) + chats.map((chat) => getUser(chat.author)), +); ``` If one `getChat(id)` out of 100 is extremely slow, the authors of the other 99 chats can't start loading even though their data is ready. @@ -1215,8 +1212,8 @@ If one `getChat(id)` out of 100 is extremely slow, the authors of the other 99 c ```tsx const chatAuthors = await Promise.all( - chatIds.map(id => getChat(id).then(chat => getUser(chat.author))) -) + chatIds.map((id) => getChat(id).then((chat) => getUser(chat.author))), +); ``` Each item independently chains `getChat` → `getUser`, so a slow chat doesn't block author fetches for the others. @@ -1230,15 +1227,15 @@ Use `React.cache()` for server-side request deduplication. Authentication and da **Usage:** ```typescript -import { cache } from 'react' +import { cache } from 'react'; export const getCurrentUser = cache(async () => { - const session = await auth() - if (!session?.user?.id) return null + const session = await auth(); + if (!session?.user?.id) return null; return await db.user.findUnique({ - where: { id: session.user.id } - }) -}) + where: { id: session.user.id }, + }); +}); ``` Within a single request, multiple calls to `getCurrentUser()` execute the query only once. @@ -1251,20 +1248,20 @@ Within a single request, multiple calls to `getCurrentUser()` execute the query ```typescript const getUser = cache(async (params: { uid: number }) => { - return await db.user.findUnique({ where: { id: params.uid } }) -}) + return await db.user.findUnique({ where: { id: params.uid } }); +}); // Each call creates new object, never hits cache -getUser({ uid: 1 }) -getUser({ uid: 1 }) // Cache miss, runs query again +getUser({ uid: 1 }); +getUser({ uid: 1 }); // Cache miss, runs query again ``` **Correct: cache hit** ```typescript -const params = { uid: 1 } -getUser(params) // Query runs -getUser(params) // Cache hit (same reference) +const params = { uid: 1 }; +getUser(params); // Query runs +getUser(params); // Cache hit (same reference) ``` If you must pass objects, pass the same reference: @@ -1296,46 +1293,47 @@ Use Next.js's `after()` to schedule work that should execute after a response is **Incorrect: blocks response** ```tsx -import { logUserAction } from '@/app/utils' +import { logUserAction } from '@/app/utils'; export async function POST(request: Request) { // Perform mutation - await updateDatabase(request) - + await updateDatabase(request); + // Logging blocks the response - const userAgent = request.headers.get('user-agent') || 'unknown' - await logUserAction({ userAgent }) - + const userAgent = request.headers.get('user-agent') || 'unknown'; + await logUserAction({ userAgent }); + return new Response(JSON.stringify({ status: 'success' }), { status: 200, - headers: { 'Content-Type': 'application/json' } - }) + headers: { 'Content-Type': 'application/json' }, + }); } ``` **Correct: non-blocking** ```tsx -import { after } from 'next/server' -import { headers, cookies } from 'next/headers' -import { logUserAction } from '@/app/utils' +import { after } from 'next/server'; +import { headers, cookies } from 'next/headers'; +import { logUserAction } from '@/app/utils'; export async function POST(request: Request) { // Perform mutation - await updateDatabase(request) - + await updateDatabase(request); + // Log after response is sent after(async () => { - const userAgent = (await headers()).get('user-agent') || 'unknown' - const sessionCookie = (await cookies()).get('session-id')?.value || 'anonymous' - - logUserAction({ sessionCookie, userAgent }) - }) - + const userAgent = (await headers()).get('user-agent') || 'unknown'; + const sessionCookie = + (await cookies()).get('session-id')?.value || 'anonymous'; + + logUserAction({ sessionCookie, userAgent }); + }); + return new Response(JSON.stringify({ status: 'success' }), { status: 200, - headers: { 'Content-Type': 'application/json' } - }) + headers: { 'Content-Type': 'application/json' }, + }); } ``` @@ -1382,12 +1380,12 @@ function useKeyboardShortcut(key: string, callback: () => void) { useEffect(() => { const handler = (e: KeyboardEvent) => { if (e.metaKey && e.key === key) { - callback() + callback(); } - } - window.addEventListener('keydown', handler) - return () => window.removeEventListener('keydown', handler) - }, [key, callback]) + }; + window.addEventListener('keydown', handler); + return () => window.removeEventListener('keydown', handler); + }, [key, callback]); } ``` @@ -1396,45 +1394,49 @@ When using the `useKeyboardShortcut` hook multiple times, each instance will reg **Correct: N instances = 1 listener** ```tsx -import useSWRSubscription from 'swr/subscription' +import useSWRSubscription from 'swr/subscription'; // Module-level Map to track callbacks per key -const keyCallbacks = new Map void>>() +const keyCallbacks = new Map void>>(); function useKeyboardShortcut(key: string, callback: () => void) { // Register this callback in the Map useEffect(() => { if (!keyCallbacks.has(key)) { - keyCallbacks.set(key, new Set()) + keyCallbacks.set(key, new Set()); } - keyCallbacks.get(key)!.add(callback) + keyCallbacks.get(key)!.add(callback); return () => { - const set = keyCallbacks.get(key) + const set = keyCallbacks.get(key); if (set) { - set.delete(callback) + set.delete(callback); if (set.size === 0) { - keyCallbacks.delete(key) + keyCallbacks.delete(key); } } - } - }, [key, callback]) + }; + }, [key, callback]); useSWRSubscription('global-keydown', () => { const handler = (e: KeyboardEvent) => { if (e.metaKey && keyCallbacks.has(e.key)) { - keyCallbacks.get(e.key)!.forEach(cb => cb()) + keyCallbacks.get(e.key)!.forEach((cb) => cb()); } - } - window.addEventListener('keydown', handler) - return () => window.removeEventListener('keydown', handler) - }) + }; + window.addEventListener('keydown', handler); + return () => window.removeEventListener('keydown', handler); + }); } function Profile() { // Multiple shortcuts will share the same listener - useKeyboardShortcut('p', () => { /* ... */ }) - useKeyboardShortcut('k', () => { /* ... */ }) + useKeyboardShortcut('p', () => { + /* ... */ + }); + useKeyboardShortcut('k', () => { + /* ... */ + }); // ... } ``` @@ -1449,34 +1451,34 @@ Add `{ passive: true }` to touch and wheel event listeners to enable immediate s ```typescript useEffect(() => { - const handleTouch = (e: TouchEvent) => console.log(e.touches[0].clientX) - const handleWheel = (e: WheelEvent) => console.log(e.deltaY) - - document.addEventListener('touchstart', handleTouch) - document.addEventListener('wheel', handleWheel) - + const handleTouch = (e: TouchEvent) => console.log(e.touches[0].clientX); + const handleWheel = (e: WheelEvent) => console.log(e.deltaY); + + document.addEventListener('touchstart', handleTouch); + document.addEventListener('wheel', handleWheel); + return () => { - document.removeEventListener('touchstart', handleTouch) - document.removeEventListener('wheel', handleWheel) - } -}, []) + document.removeEventListener('touchstart', handleTouch); + document.removeEventListener('wheel', handleWheel); + }; +}, []); ``` **Correct:** ```typescript useEffect(() => { - const handleTouch = (e: TouchEvent) => console.log(e.touches[0].clientX) - const handleWheel = (e: WheelEvent) => console.log(e.deltaY) - - document.addEventListener('touchstart', handleTouch, { passive: true }) - document.addEventListener('wheel', handleWheel, { passive: true }) - + const handleTouch = (e: TouchEvent) => console.log(e.touches[0].clientX); + const handleWheel = (e: WheelEvent) => console.log(e.deltaY); + + document.addEventListener('touchstart', handleTouch, { passive: true }); + document.addEventListener('wheel', handleWheel, { passive: true }); + return () => { - document.removeEventListener('touchstart', handleTouch) - document.removeEventListener('wheel', handleWheel) - } -}, []) + document.removeEventListener('touchstart', handleTouch); + document.removeEventListener('wheel', handleWheel); + }; +}, []); ``` **Use passive when:** tracking/analytics, logging, any listener that doesn't call `preventDefault()`. @@ -1493,43 +1495,43 @@ SWR enables request deduplication, caching, and revalidation across component in ```tsx function UserList() { - const [users, setUsers] = useState([]) + const [users, setUsers] = useState([]); useEffect(() => { fetch('/api/users') - .then(r => r.json()) - .then(setUsers) - }, []) + .then((r) => r.json()) + .then(setUsers); + }, []); } ``` **Correct: multiple instances share one request** ```tsx -import useSWR from 'swr' +import useSWR from 'swr'; function UserList() { - const { data: users } = useSWR('/api/users', fetcher) + const { data: users } = useSWR('/api/users', fetcher); } ``` **For immutable data:** ```tsx -import { useImmutableSWR } from '@/lib/swr' +import { useImmutableSWR } from '@/lib/swr'; function StaticContent() { - const { data } = useImmutableSWR('/api/config', fetcher) + const { data } = useImmutableSWR('/api/config', fetcher); } ``` **For mutations:** ```tsx -import { useSWRMutation } from 'swr/mutation' +import { useSWRMutation } from 'swr/mutation'; function UpdateButton() { - const { trigger } = useSWRMutation('/api/user', updateUser) - return + const { trigger } = useSWRMutation('/api/user', updateUser); + return ; } ``` @@ -1545,18 +1547,18 @@ Add version prefix to keys and store only needed fields. Prevents schema conflic ```typescript // No version, stores everything, no error handling -localStorage.setItem('userConfig', JSON.stringify(fullUserObject)) -const data = localStorage.getItem('userConfig') +localStorage.setItem('userConfig', JSON.stringify(fullUserObject)); +const data = localStorage.getItem('userConfig'); ``` **Correct:** ```typescript -const VERSION = 'v2' +const VERSION = 'v2'; function saveConfig(config: { theme: string; language: string }) { try { - localStorage.setItem(`userConfig:${VERSION}`, JSON.stringify(config)) + localStorage.setItem(`userConfig:${VERSION}`, JSON.stringify(config)); } catch { // Throws in incognito/private browsing, quota exceeded, or disabled } @@ -1564,21 +1566,24 @@ function saveConfig(config: { theme: string; language: string }) { function loadConfig() { try { - const data = localStorage.getItem(`userConfig:${VERSION}`) - return data ? JSON.parse(data) : null + const data = localStorage.getItem(`userConfig:${VERSION}`); + return data ? JSON.parse(data) : null; } catch { - return null + return null; } } // Migration from v1 to v2 function migrate() { try { - const v1 = localStorage.getItem('userConfig:v1') + const v1 = localStorage.getItem('userConfig:v1'); if (v1) { - const old = JSON.parse(v1) - saveConfig({ theme: old.darkMode ? 'dark' : 'light', language: old.lang }) - localStorage.removeItem('userConfig:v1') + const old = JSON.parse(v1); + saveConfig({ + theme: old.darkMode ? 'dark' : 'light', + language: old.lang, + }); + localStorage.removeItem('userConfig:v1'); } } catch {} } @@ -1590,10 +1595,13 @@ function migrate() { // User object has 20+ fields, only store what UI needs function cachePrefs(user: FullUser) { try { - localStorage.setItem('prefs:v1', JSON.stringify({ - theme: user.preferences.theme, - notifications: user.preferences.notifications - })) + localStorage.setItem( + 'prefs:v1', + JSON.stringify({ + theme: user.preferences.theme, + notifications: user.preferences.notifications, + }), + ); } catch {} } ``` @@ -1620,15 +1628,15 @@ If a value can be computed from current props/state, do not store it in state or ```tsx function Form() { - const [firstName, setFirstName] = useState('First') - const [lastName, setLastName] = useState('Last') - const [fullName, setFullName] = useState('') + const [firstName, setFirstName] = useState('First'); + const [lastName, setLastName] = useState('Last'); + const [fullName, setFullName] = useState(''); useEffect(() => { - setFullName(firstName + ' ' + lastName) - }, [firstName, lastName]) + setFullName(firstName + ' ' + lastName); + }, [firstName, lastName]); - return

{fullName}

+ return

{fullName}

; } ``` @@ -1636,11 +1644,11 @@ function Form() { ```tsx function Form() { - const [firstName, setFirstName] = useState('First') - const [lastName, setLastName] = useState('Last') - const fullName = firstName + ' ' + lastName + const [firstName, setFirstName] = useState('First'); + const [lastName, setLastName] = useState('Last'); + const fullName = firstName + ' ' + lastName; - return

{fullName}

+ return

{fullName}

; } ``` @@ -1656,14 +1664,14 @@ Don't subscribe to dynamic state (searchParams, localStorage) if you only read i ```tsx function ShareButton({ chatId }: { chatId: string }) { - const searchParams = useSearchParams() + const searchParams = useSearchParams(); const handleShare = () => { - const ref = searchParams.get('ref') - shareChat(chatId, { ref }) - } + const ref = searchParams.get('ref'); + shareChat(chatId, { ref }); + }; - return + return ; } ``` @@ -1672,12 +1680,12 @@ function ShareButton({ chatId }: { chatId: string }) { ```tsx function ShareButton({ chatId }: { chatId: string }) { const handleShare = () => { - const params = new URLSearchParams(window.location.search) - const ref = params.get('ref') - shareChat(chatId, { ref }) - } + const params = new URLSearchParams(window.location.search); + const ref = params.get('ref'); + shareChat(chatId, { ref }); + }; - return + return ; } ``` @@ -1694,10 +1702,10 @@ Calling `useMemo` and comparing hook dependencies may consume more resources tha ```tsx function Header({ user, notifications }: Props) { const isLoading = useMemo(() => { - return user.isLoading || notifications.isLoading - }, [user.isLoading, notifications.isLoading]) + return user.isLoading || notifications.isLoading; + }, [user.isLoading, notifications.isLoading]); - if (isLoading) return + if (isLoading) return ; // return some markup } ``` @@ -1706,9 +1714,9 @@ function Header({ user, notifications }: Props) { ```tsx function Header({ user, notifications }: Props) { - const isLoading = user.isLoading || notifications.isLoading + const isLoading = user.isLoading || notifications.isLoading; - if (isLoading) return + if (isLoading) return ; // return some markup } ``` @@ -1731,7 +1739,7 @@ function UserProfile({ user, theme }) { src={user.avatarUrl} className={theme === 'dark' ? 'avatar-dark' : 'avatar-light'} /> - ) + ); // Defined inside to access `user` - BAD const Stats = () => ( @@ -1739,14 +1747,14 @@ function UserProfile({ user, theme }) { {user.followers} followers {user.posts} posts - ) + ); return (
- ) + ); } ``` @@ -1761,7 +1769,7 @@ function Avatar({ src, theme }: { src: string; theme: string }) { src={src} className={theme === 'dark' ? 'avatar-dark' : 'avatar-light'} /> - ) + ); } function Stats({ followers, posts }: { followers: number; posts: number }) { @@ -1770,7 +1778,7 @@ function Stats({ followers, posts }: { followers: number; posts: number }) { {followers} followers {posts} posts - ) + ); } function UserProfile({ user, theme }) { @@ -1779,7 +1787,7 @@ function UserProfile({ user, theme }) { - ) + ); } ``` @@ -1836,12 +1844,12 @@ Extract expensive work into memoized components to enable early returns before c ```tsx function Profile({ user, loading }: Props) { const avatar = useMemo(() => { - const id = computeAvatarId(user) - return - }, [user]) + const id = computeAvatarId(user); + return ; + }, [user]); - if (loading) return - return
{avatar}
+ if (loading) return ; + return
{avatar}
; } ``` @@ -1849,17 +1857,17 @@ function Profile({ user, loading }: Props) { ```tsx const UserAvatar = memo(function UserAvatar({ user }: { user: User }) { - const id = useMemo(() => computeAvatarId(user), [user]) - return -}) + const id = useMemo(() => computeAvatarId(user), [user]); + return ; +}); function Profile({ user, loading }: Props) { - if (loading) return + if (loading) return ; return (
- ) + ); } ``` @@ -1875,16 +1883,16 @@ Specify primitive dependencies instead of objects to minimize effect re-runs. ```tsx useEffect(() => { - console.log(user.id) -}, [user]) + console.log(user.id); +}, [user]); ``` **Correct: re-runs only when id changes** ```tsx useEffect(() => { - console.log(user.id) -}, [user.id]) + console.log(user.id); +}, [user.id]); ``` **For derived state, compute outside effect:** @@ -1893,17 +1901,17 @@ useEffect(() => { // Incorrect: runs on width=767, 766, 765... useEffect(() => { if (width < 768) { - enableMobileMode() + enableMobileMode(); } -}, [width]) +}, [width]); // Correct: runs only on boolean transition -const isMobile = width < 768 +const isMobile = width < 768; useEffect(() => { if (isMobile) { - enableMobileMode() + enableMobileMode(); } -}, [isMobile]) +}, [isMobile]); ``` ### 5.8 Put Interaction Logic in Event Handlers @@ -1916,17 +1924,17 @@ If a side effect is triggered by a specific user action (submit, click, drag), r ```tsx function Form() { - const [submitted, setSubmitted] = useState(false) - const theme = useContext(ThemeContext) + const [submitted, setSubmitted] = useState(false); + const theme = useContext(ThemeContext); useEffect(() => { if (submitted) { - post('/api/register') - showToast('Registered', theme) + post('/api/register'); + showToast('Registered', theme); } - }, [submitted, theme]) + }, [submitted, theme]); - return + return ; } ``` @@ -1934,14 +1942,14 @@ function Form() { ```tsx function Form() { - const theme = useContext(ThemeContext) + const theme = useContext(ThemeContext); function handleSubmit() { - post('/api/register') - showToast('Registered', theme) + post('/api/register'); + showToast('Registered', theme); } - return + return ; } ``` @@ -1957,12 +1965,12 @@ When a hook contains multiple independent tasks with different dependencies, spl ```tsx const sortedProducts = useMemo(() => { - const filtered = products.filter((p) => p.category === category) + const filtered = products.filter((p) => p.category === category); const sorted = filtered.toSorted((a, b) => - sortOrder === "asc" ? a.price - b.price : b.price - a.price - ) - return sorted -}, [products, category, sortOrder]) + sortOrder === 'asc' ? a.price - b.price : b.price - a.price, + ); + return sorted; +}, [products, category, sortOrder]); ``` **Correct: filtering only recomputes when products or category change** @@ -1970,16 +1978,16 @@ const sortedProducts = useMemo(() => { ```tsx const filteredProducts = useMemo( () => products.filter((p) => p.category === category), - [products, category] -) + [products, category], +); const sortedProducts = useMemo( () => filteredProducts.toSorted((a, b) => - sortOrder === "asc" ? a.price - b.price : b.price - a.price + sortOrder === 'asc' ? a.price - b.price : b.price - a.price, ), - [filteredProducts, sortOrder] -) + [filteredProducts, sortOrder], +); ``` This pattern also applies to `useEffect` when combining unrelated side effects: @@ -1988,21 +1996,21 @@ This pattern also applies to `useEffect` when combining unrelated side effects: ```tsx useEffect(() => { - analytics.trackPageView(pathname) - document.title = `${pageTitle} | My App` -}, [pathname, pageTitle]) + analytics.trackPageView(pathname); + document.title = `${pageTitle} | My App`; +}, [pathname, pageTitle]); ``` **Correct: effects run independently** ```tsx useEffect(() => { - analytics.trackPageView(pathname) -}, [pathname]) + analytics.trackPageView(pathname); +}, [pathname]); useEffect(() => { - document.title = `${pageTitle} | My App` -}, [pageTitle]) + document.title = `${pageTitle} | My App`; +}, [pageTitle]); ``` **Note:** If your project has [React Compiler](https://react.dev/learn/react-compiler) enabled, it automatically optimizes dependency tracking and may handle some of these cases for you. @@ -2017,9 +2025,9 @@ Subscribe to derived boolean state instead of continuous values to reduce re-ren ```tsx function Sidebar() { - const width = useWindowWidth() // updates continuously - const isMobile = width < 768 - return