1 minute read

Modern React Performance Tips

The most important practical techniques to make your modern React applications faster.

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.

'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

Share This Article