1. Leverage with React Compiler: Smart Memoization
The React compiler automatically handles memoization for you. No more guessing, no more manual optimization.
// Before: Manual memoization (no longer needed)
function TodoList({ todos, filter }) {
const filtered = useMemo(() => todos.filter((todo) => todo.status === filter), [todos, filter])
const handleComplete = useCallback((todoId) => {
analytics.track('todo_completed', { todoId })
}, [])
return <TodoItems data={filtered} onComplete={handleComplete} />
}
// After: Let the compiler handle it
function TodoList({ todos, filter }) {
const filtered = todos.filter((todo) => todo.status === filter)
const handleComplete = (todoId) => {
analytics.track('todo_completed', { todoId })
}
return <TodoItems data={filtered} onComplete={handleComplete} />
}Why I Love This
-
Smarter optimization - The compiler knows better than we do when to memoize
-
No more bugs - Goodbye broken dependency arrays
-
Cleaner code - Focus on functionality, not performance micro-management
-
Smaller bundles - Less memoization hooks = less JavaScript
This removes so much cognitive overhead. Now I can focus on building great user experiences instead of debating whether something needs useCallback.
2. Server Components: Keep It Server-Side When You Can
Server Components let you do the heavy lifting on the server instead of shipping everything to the client. Smart boundaries matter here.
The Pattern
// Client component fetching data
'use client'
function TodoApp() {
const [todos, setTodos] = useState([])
useEffect(() => {
fetch('/api/todos')
.then((res) => res.json())
.then(setTodos)
}, [])
if (!todos.length) return <Loading />
return <TodoList todos={todos} />
}
// Server component
async function TodoApp() {
const todos = await db.todo.findMany() // Direct database access
return <TodoList todos={todos} /> // No loading states, no client JS
}Mix Them Smart
// Comments.tsx (Client Component)
'use client'
function AddTodoForm() {
const [title, setTitle] = useState('')
// Interactive stuff lives here
}
// BlogPost.tsx (Server Component)
async function TodoApp() {
const todos = await db.todo.findMany()
return <TodoList todos={todos} addTodo={<AddTodoForm />} />
}When I Use Server Components
-
Data fetching - Skip the loading states, get data directly
-
Static content - Headers, footers, article content
-
Database queries - No API layer needed
-
Heavy dependencies - Keep large libraries server-side
The key is being intentional about the boundary. Server for data, client for interaction.
3. Use Transitions: Keep the UI Smooth
Use transitions for heavy updates that don't need to block the UI. Keep interactions snappy, let the background stuff wait.
The Right Way to Handle Search
'use client'
function TodoSearch() {
const [query, setQuery] = useState('')
const [todos, setTodos] = useState([])
const [isPending, startTransition] = useTransition()
function handleSearch(e) {
const value = e.target.value
setQuery(value) // Input stays responsive
startTransition(() => {
// Heavy filtering can wait
filterTodos(value).then(setTodos)
})
}
return (
<div>
<input value={query} onChange={handleSearch} />
{isPending && <Spinner />}
<TodoList todos={todos} />
</div>
)
}Why This Works
-
Responsive inputs - Typing never feels sluggish
-
Smart prioritization - User interactions come first
-
No blocking - Heavy operations run in the background
-
Auto-interruption - New searches cancel old ones
The input stays buttery smooth while the results catch up when they can.
4. Dynamic Content with Partial Prerendering
Next.js 14.1+ lets you mix static and dynamic content smartly. Fast static parts load instantly, dynamic stuff streams in when ready.
import { Suspense } from 'react'
import { TodoStats } from './todo-stats'
import { RecentTodos } from './recent-todos'
export default function Dashboard() {
return (
<div className="dashboard">
{/* Static: prerendered, loads instantly */}
<TodoStats />
{/* Dynamic: streams in when ready */}
<Suspense fallback={<div>Loading todos...</div>}>
<RecentTodos />
</Suspense>
</div>
)
}Benefits
-
Instant static content - Header, navigation, layout appear immediately
-
Smart streaming - Dynamic data fills in progressively
-
Automatic loading states - Suspense handles the in-between
Perfect for dashboards where some content is static and some needs real-time data.
5. Optimize Context Usage
Context can be a performance killer if you're not careful. Split it up or use selectors to avoid unnecessary re-renders.
'use client'
// Everything re-renders when anything changes
const TodoContext = createContext()
function TodoProvider({ children }) {
const [todos, setTodos] = useState([])
const [filter, setFilter] = useState('all')
const [user, setUser] = useState(null)
return (
<TodoContext.Provider value={{ todos, filter, user, setTodos, setFilter, setUser }}>
{children}
</TodoContext.Provider>
)
}
// Separate concerns, fewer re-renders
const TodoStateContext = createContext()
const TodoDispatchContext = createContext()
function TodoProvider({ children }) {
const [state, dispatch] = useReducer(todoReducer, initialState)
return (
<TodoStateContext.Provider value={state}>
<TodoDispatchContext.Provider value={dispatch}>{children}</TodoDispatchContext.Provider>
</TodoStateContext.Provider>
)
}
// Even better with selectors
function useFilter() {
return useContextSelector(TodoStateContext, (state) => state.filter)
}Why This Matters
-
Fewer re-renders - Components only update when their data changes
-
Better performance - Large lists don't re-render for unrelated updates
-
Cleaner separation - State and actions live in different contexts
Only the components that actually need the data will re-render when it changes.
6. Debounce and Throttle Events
Don't call expensive functions on every keystroke or scroll. Wait for the user to pause, then do the work.
'use client'
import { debounce } from 'lodash-es'
function TodoSearch() {
const [results, setResults] = useState([])
// API call on every keystroke
const handleSearch = (e) => {
searchTodos(e.target.value).then(setResults)
}
// Wait 300ms after user stops typing
const debouncedSearch = debounce((query) => {
searchTodos(query).then(setResults)
}, 300)
const handleChange = (e) => {
debouncedSearch(e.target.value)
}
return <input onChange={handleSearch} placeholder="Search todos..." />
}7. Virtualize Long Lists
Don't render 10,000 todo items at once. Only show what the user can actually see, keep the rest virtual.
The Heavy Way
'use client'
// Rendering all 10,000 todos = slow
function TodoList({ todos }) {
return (
<ul>
{todos.map((todo) => (
<TodoItem key={todo.id} todo={todo} />
))}
</ul>
)
}With Virtualization
'use client'
import { Virtuoso } from 'react-virtuoso'
function TodoList({ todos }) {
return (
<Virtuoso
data={todos}
itemContent={(index, todo) => <TodoItem todo={todo} />}
overscan={10} // Render a few extra items for smooth scrolling
style={{ height: '600px' }} // Required for calculations
/>
)
}When You Need This
-
Large datasets - More than 100-200 items
-
Complex items - Rich todo cards with images, tags, etc.
-
Mobile performance - Limited memory and processing power
-
Infinite lists - Loading more data as you scroll
Your browser will thank you for not trying to render everything at once.
Other Good Options
-
react-window - Lightweight, simple API, great for basic virtualization
-
tanstack-virtual - Headless, excellent TypeScript support, framework agnostic
-
react-virtualized - Full-featured but heavier, lots of components