A complete guide to modern React — no useEffect abuse, no performance guessing, just clean architecture you'll actually use.
Why This Guide Exists
Most React tutorials are still teaching 2020 patterns. In 2026, the ecosystem has shifted dramatically: React 19 is stable, the compiler handles most memoization automatically, and useEffect should be a last resort — not your go-to for data fetching, derived state, or event responses.
This guide walks you from project setup to production-ready patterns, with a cheat sheet you can bookmark.
1. Scaffolding: What to Use in 2026
If you're building a full-stack or SSR app
npx create-next-app@latest my-app --typescript --tailwind --eslint --app
Next.js 15+ with the App Router is the default for production apps. It gives you Server Components, streaming, and partial pre-rendering out of the box.
If you're building a pure client SPA
npm create vite@latest my-app -- --template react-ts
Vite is the standard bundler for SPAs. Fast HMR, zero config, ESM-first.
If you're building something UI-heavy or a design system
npm create remix@latest
Remix is excellent when you care deeply about progressive enhancement and form handling.
2. Folder Structure (Scalable from Day 1)
src/
├── app/ # Pages / routing (Next.js) or routes/
├── components/
│ ├── ui/ # Generic, reusable UI primitives
│ └── features/ # Feature-specific components
├── hooks/ # Custom hooks
├── lib/ # Utilities, API clients, helpers
├── stores/ # Global state (Zustand, Jotai, etc.)
├── types/ # Shared TypeScript types
└── styles/ # Global CSS / Tailwind config
Rule: Components should not import from pages/ or app/. Data flows down. Side effects live in hooks.
3. React 19 Features You Should Actually Use
use() — The New Data Primitive
import { use, Suspense } from 'react';
async function fetchUser(id: string) {
const res = await fetch(`/api/users/${id}`);
return res.json();
}
// In a Server Component or with a cached promise:
function UserCard({ userPromise }: { userPromise: Promise<User> }) {
const user = use(userPromise); // unwraps the promise
return <div>{user.name}</div>;
}
// Wrap in Suspense at the boundary
<Suspense fallback={<Skeleton />}>
<UserCard userPromise={fetchUser('123')} />
</Suspense>
useOptimistic — Instant UI, Real Sync
import { useOptimistic } from 'react';
function LikeButton({ post }: { post: Post }) {
const [optimisticLikes, addOptimisticLike] = useOptimistic(
post.likes,
(currentLikes, increment: number) => currentLikes + increment
);
async function handleLike() {
addOptimisticLike(1); // instant update
await likePost(post.id); // real request in background
}
return (
<button onClick={handleLike}>
{optimisticLikes} likes
</button>
);
}
useActionState — Form State Without the Chaos
import { useActionState } from 'react';
async function createPost(prevState: any, formData: FormData) {
const title = formData.get('title') as string;
if (!title) return { error: 'Title is required' };
await savePost({ title });
return { success: true };
}
function PostForm() {
const [state, action, isPending] = useActionState(createPost, null);
return (
<form action={action}>
<input name="title" />
{state?.error && <p>{state.error}</p>}
<button disabled={isPending}>
{isPending ? 'Saving...' : 'Create Post'}
</button>
</form>
);
}
4. Stop Using useEffect for These Things
This is the most common mistake. Here's what to replace:
❌ Deriving state with useEffect
// Wrong
const [fullName, setFullName] = useState('');
useEffect(() => {
setFullName(`${firstName} ${lastName}`);
}, [firstName, lastName]);
// Correct — just compute it
const fullName = `${firstName} ${lastName}`;
❌ Fetching data with useEffect
// Wrong
useEffect(() => {
fetch('/api/posts').then(r => r.json()).then(setPosts);
}, []);
// Correct — use a data fetching library
import { useQuery } from '@tanstack/react-query';
function Posts() {
const { data, isLoading } = useQuery({
queryKey: ['posts'],
queryFn: () => fetch('/api/posts').then(r => r.json())
});
}
Or even better — use Server Components in Next.js and fetch server-side:
// app/posts/page.tsx — This is a Server Component
export default async function PostsPage() {
const posts = await getPosts(); // direct DB/API call
return <PostList posts={posts} />;
}
❌ Responding to events with useEffect
// Wrong
useEffect(() => {
if (submitted) {
sendAnalytics();
resetForm();
}
}, [submitted]);
// Correct — handle it in the event handler
function handleSubmit() {
sendAnalytics();
resetForm();
setSubmitted(true);
}
✅ When useEffect IS appropriate
- Setting up a third-party library that imperatively mutates the DOM
- WebSocket or EventSource subscriptions (with cleanup)
- Syncing to
localStorageorsessionStorage - Firing analytics on route change (sparingly)
5. State Management in 2026
| Need | Tool |
|---|---|
| Local UI state |
useState / useReducer
|
| Server data + caching | TanStack Query |
| Global client state | Zustand or Jotai |
| Forms | React Hook Form + Zod |
| URL state |
nuqs (Next.js) or useSearchParams
|
| Server state (Next.js) | Server Actions + revalidatePath
|
Zustand setup (minimal):
import { create } from 'zustand';
interface CartStore {
items: CartItem[];
addItem: (item: CartItem) => void;
removeItem: (id: string) => void;
}
export const useCartStore = create<CartStore>((set) => ({
items: [],
addItem: (item) => set((state) => ({ items: [...state.items, item] })),
removeItem: (id) => set((state) => ({
items: state.items.filter(i => i.id !== id)
})),
}));
6. Performance in 2026 (The React Compiler Changes Everything)
React 19 ships with the React Compiler (previously "React Forget"). It automatically memoizes components and values — meaning useMemo, useCallback, and React.memo are rarely needed manually.
What the compiler handles automatically
- Skipping re-renders when props haven't changed
- Stabilizing callback references
- Memoizing expensive computations
What you still need to think about
Code splitting — always split by route:
import { lazy, Suspense } from 'react';
const HeavyDashboard = lazy(() => import('./HeavyDashboard'));
function App() {
return (
<Suspense fallback={<LoadingScreen />}>
<HeavyDashboard />
</Suspense>
);
}
Virtualize long lists — use TanStack Virtual:
import { useVirtualizer } from '@tanstack/react-virtual';
function VirtualList({ items }: { items: string[] }) {
const parentRef = useRef<HTMLDivElement>(null);
const virtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 50,
});
return (
<div ref={parentRef} style={{ height: '400px', overflow: 'auto' }}>
<div style={{ height: `${virtualizer.getTotalSize()}px`, position: 'relative' }}>
{virtualizer.getVirtualItems().map(item => (
<div
key={item.key}
style={{ position: 'absolute', top: item.start, width: '100%' }}
>
{items[item.index]}
</div>
))}
</div>
</div>
);
}
Images — always use Next.js <Image> or a CDN:
import Image from 'next/image';
<Image
src="/hero.jpg"
alt="Hero"
width={1200}
height={600}
priority // for above-the-fold images
placeholder="blur"
blurDataURL={blurUrl}
/>
7. TypeScript Patterns Worth Knowing
Component props with variants
type ButtonProps = {
variant: 'primary' | 'secondary' | 'ghost';
size?: 'sm' | 'md' | 'lg';
isLoading?: boolean;
} & React.ButtonHTMLAttributes<HTMLButtonElement>;
function Button({ variant, size = 'md', isLoading, children, ...rest }: ButtonProps) {
return (
<button
disabled={isLoading}
className={cn(buttonVariants({ variant, size }))}
{...rest}
>
{isLoading ? <Spinner /> : children}
</button>
);
}
Discriminated unions for component state
type AsyncState<T> =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: T }
| { status: 'error'; error: string };
function DataView({ state }: { state: AsyncState<User[]> }) {
if (state.status === 'loading') return <Skeleton />;
if (state.status === 'error') return <Error message={state.error} />;
if (state.status === 'success') return <UserList users={state.data} />;
return null;
}
8. The 2026 Cheat Sheet
Hooks reference
| Hook | Use it for |
|---|---|
useState |
Simple local state |
useReducer |
Complex local state with multiple sub-values |
useRef |
DOM refs, mutable values that don't trigger re-render |
useContext |
Consuming context (theme, auth, locale) |
use() |
Unwrapping promises and context in any component |
useOptimistic |
Instant UI feedback while async operation runs |
useActionState |
Managing form state tied to a server action |
useTransition |
Marking non-urgent state updates |
useDeferredValue |
Deferring re-rendering of slow sub-trees |
useEffect |
LAST RESORT — external system sync only |
When to reach for what
| Scenario | Solution |
|---|---|
| Data from server | TanStack Query or Server Components |
| Form handling |
useActionState + Server Actions, or React Hook Form |
| Global state | Zustand (simple) or Jotai (atomic) |
| URL as state | nuqs |
| List of 500+ items | TanStack Virtual |
| Async feedback | useOptimistic |
| Heavy component |
lazy() + Suspense
|
| Derived value | Compute inline, no state |
| Animation | Framer Motion or CSS transitions |
Anti-patterns to avoid
| Anti-pattern | Replacement |
|---|---|
useEffect for data fetching |
TanStack Query / Server Components |
useEffect for derived state |
Compute inline |
useEffect for event responses |
Event handler |
| Prop drilling 3+ levels | Zustand / context |
React.memo everywhere |
Let the compiler handle it |
Manual useMemo/useCallback
|
Let the compiler handle it |
any in TypeScript |
Discriminated unions, generics |
| Giant components (500+ lines) | Split by concern |
9. Recommended Stack (2026)
Framework: Next.js 15 (App Router)
Language: TypeScript (strict mode)
Styling: Tailwind CSS v4 + shadcn/ui
Data fetching: TanStack Query v5
Global state: Zustand
Forms: React Hook Form + Zod
Testing: Vitest + Testing Library + Playwright
Linting: ESLint + Prettier + TypeScript strict
Deployment: Vercel / Cloudflare Workers
Final Thoughts
The shift from "React as a UI library" to "React as an application framework" is complete in 2026. The mental model has changed:
- Server first. Render as much as you can on the server.
- Data co-located with components. No more global fetch orchestration.
- Less manual optimization. The compiler does the heavy lifting.
-
useEffectis a code smell. If you're reaching for it, ask why first.
Build from these foundations and you'll spend less time debugging re-renders and more time shipping features.
Found this useful? Drop a like and follow — I write about React, TypeScript, and modern web architecture.
Tags: #react #javascript #typescript #webdev #frontend
Top comments (0)