DEV Community

Vitaly
Vitaly

Posted on

Why your React app feels slow (and how to fix it)

Everything seems fine… until it isn’t.

You can have a React app that feels instant with a small number of users, and then suddenly it starts struggling as load grows.
In most cases, the issue isn’t the server or network — it’s inefficient rendering on the client.

The real gap between an “okay” app and a fast one is how predictable and controlled your renders are.

What actually triggers re-renders

Before trying to optimize anything, it’s important to understand what causes updates in React.

A component will re-render if:

  • its internal state updates
  • it receives new props
  • its parent updates and forces a new render cycle

That last case is where things usually go wrong.
A single update near the top of your component tree can ripple down and cause a lot of unnecessary work.

This is why performance issues often scale with your app — not because the logic changes, but because the rendering cost multiplies.

Stop guessing

Before touching any optimization, figure out where time is actually spent.

Start simple — use React DevTools Profiler:

  1. open DevTools → switch to Profiler
  2. hit record
  3. interact with your app like a real user
  4. stop recording
  5. check which components take the most time to render

Don’t optimize blindly. Find the bottleneck first.

Quick way to catch useless re-renders

During development, it’s useful to see why a component updates.

You can drop a small hook that compares previous and current props and logs the difference:

// hooks/useRenderDebug.ts
import { useEffect, useRef } from 'react';

export function useRenderDebug(
  label: string,
  props: Record<string, unknown>
) {
  const prevProps = useRef(props);

  useEffect(() => {
    const diff: Record<string, { before: unknown; after: unknown }> = {};

    Object.keys(props).forEach((key) => {
      if (prevProps.current[key] !== props[key]) {
        diff[key] = {
          before: prevProps.current[key],
          after: props[key],
        };
      }
    });

    if (Object.keys(diff).length) {
      console.log(`[render-debug] ${label}`, diff);
    }

    prevProps.current = props;
  });
}
Enter fullscreen mode Exit fullscreen mode

1.Memoization is not a silver bullet (but it helps)

useMemo and useCallback exist to avoid unnecessary work — not to make your app magically faster.

Use them to prevent:

  • repeated heavy calculations
  • unstable function references that trigger re-renders

Avoid recomputing on every render

If you derive data inside render — it will run every time.

❌ naive approach:

const Products = ({ products, filter }) => {
  const filtered = products.filter(p => p.category === filter);
  const sorted = filtered.sort((a, b) => b.price - a.price);

  return <ProductList items={sorted} />;
};
Enter fullscreen mode Exit fullscreen mode

Every render = filter + sort again.

✅ better approach:

const Products = ({ products, filter }) => {
  const sorted = useMemo(() => {
    const filtered = products.filter(p => p.category === filter);
    return filtered.sort((a, b) => b.price - a.price);
  }, [products, filter]);

  return <ProductList items={sorted} />;
};
Enter fullscreen mode Exit fullscreen mode

Now it recalculates only when inputs change.

Function identity matters

Functions are recreated on every render.

If you pass them down — children re-render.

❌ unstable reference:

const Parent = () => {
  const [count, setCount] = useState(0);

  const handleClick = () => {
    console.log('clicked');
  };

  return <ExpensiveChild onClick={handleClick} />;
};
Enter fullscreen mode Exit fullscreen mode

Child re-renders every time because the function is new.

✅ stable reference:

const Parent = () => {
  const [count, setCount] = useState(0);

  const handleClick = useCallback(() => {
    console.log('clicked');
  }, []);

  return <ExpensiveChild onClick={handleClick} />;
};
Enter fullscreen mode Exit fullscreen mode

Now the reference is stable.

When NOT to use memoization

Blindly wrapping everything is a mistake.

Memoization also costs:

  • memory
  • dependency comparisons

Use it only when:

  • you actually have heavy computations (filter/sort large data)
  • the result is passed to a memoized component
  • you're fixing real re-render issues, not guessing

2. React.memo is about control, not magic

React.memo doesn’t make components faster by itself.

It simply prevents re-renders when props didn’t change.

Use it when a component receives stable props but still re-renders because its parent updated.

const ProductList = React.memo(({ items, onSelect }) => {
  return (
    <ul>
      {items.map(item => (
        <li key={item.id} onClick={() => onSelect(item.id)}>
          {item.name}  ${item.price}
        </li>
      ))}
    </ul>
  );
});
Enter fullscreen mode Exit fullscreen mode

If items and onSelect are stable → this component won’t re-render.


Custom comparison (advanced case)

Sometimes shallow comparison isn’t enough.

You can control when React should skip re-rendering:

const Chart = React.memo(
  ({ data, config }) => {
    return <canvas ref={chartRef} />;
  },
  (prev, next) => {
    return (
      prev.data.length === next.data.length &&
      prev.config.type === next.config.type
    );
  }
);
Enter fullscreen mode Exit fullscreen mode

Now the component updates only when you decide.


When it actually makes sense

Don’t wrap everything in React.memo.

Use it when:

  • the component is expensive to render
  • props are stable most of the time
  • re-renders come from parent updates, not real changes

3. Render less, not faster

If you're rendering thousands of items, the problem isn’t React — it’s the DOM.

Rendering 1,000+ elements = 1,000+ DOM nodes.

That’s where performance dies.

Virtualization solves this by rendering only what’s visible on screen and reusing nodes while scrolling.


Using react-window

npm install react-window
Enter fullscreen mode Exit fullscreen mode
import { FixedSizeList } from 'react-window';

const BigList = ({ items }) => {
  const Row = ({ index, style }) => (
    <div style={style} className="flex items-center p-4 border-b">
      <span>{items[index].name}</span>
      <span className="ml-auto">${items[index].price}</span>
    </div>
  );

  return (
    <FixedSizeList
      height={600}
      itemCount={items.length}
      itemSize={60}
      width="100%"
    >
      {Row}
    </FixedSizeList>
  );
};
Enter fullscreen mode Exit fullscreen mode

Instead of rendering 10,000 nodes, you render ~15–20.

Scroll becomes smooth because the browser has less work to do.

If your rows have dynamic height → use VariableSizeList

For grids → use FixedSizeGrid


4. Load code only when it’s needed

Shipping your entire bundle upfront is wasteful.

Split it by routes and heavy components.

import { lazy, Suspense } from 'react';

// loaded only when route is visited
const Dashboard = lazy(() => import('./pages/Dashboard'));
const Analytics = lazy(() => import('./pages/Analytics'));
const Settings = lazy(() => import('./pages/Settings'));

const App = () => (
  <Suspense fallback={<LoadingSpinner />}>
    <Routes>
      <Route path="/dashboard" element={<Dashboard />} />
      <Route path="/analytics" element={<Analytics />} />
      <Route path="/settings" element={<Settings />} />
    </Routes>
  </Suspense>
);
Enter fullscreen mode Exit fullscreen mode

Lazy load heavy stuff (like charts)

const ChartComponent = lazy(() => import('./components/Chart'));

const Dashboard = () => (
  <div>
    <h1>Dashboard</h1>

    <Suspense fallback={<div>Loading chart...</div>}>
      <ChartComponent data={chartData} />
    </Suspense>
  </div>
);
Enter fullscreen mode Exit fullscreen mode

Don’t load what the user might never see.

5. Stop firing expensive logic on every keystroke

Search inputs, resize handlers, scroll listeners — all of these can kill performance if they run too often.

Don’t execute logic on every event. Delay it.

import { useState, useMemo } from 'react';
import debounce from 'lodash.debounce';

const SearchBar = ({ onSearch }) => {
  const [query, setQuery] = useState('');

  const debouncedSearch = useMemo(
    () => debounce((value: string) => onSearch(value), 300),
    [onSearch]
  );

  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    const value = e.target.value;
    setQuery(value);
    debouncedSearch(value);
  };

  return (
    <input
      value={query}
      onChange={handleChange}
      placeholder="Search..."
    />
  );
};
Enter fullscreen mode Exit fullscreen mode

Instead of firing on every key press → you wait and execute once.


6. Images are often your biggest bottleneck

Images are usually heavier than your JS.

If you ignore them — no optimization will save you.


Lazy load everything below the fold

<img
  src={product.image}
  alt={product.name}
  loading="lazy"
  decoding="async"
  width={400}
  height={300}
/>
Enter fullscreen mode Exit fullscreen mode

Let the browser load images only when needed.


Use modern formats (with fallback)

<picture>
  <source srcSet="/hero.avif" type="image/avif" />
  <source srcSet="/hero.webp" type="image/webp" />
  <img src="/hero.jpg" alt="Hero" />
</picture>
Enter fullscreen mode Exit fullscreen mode

Smaller size → faster load → better UX.

Final thoughts

React performance is not about tricks. It’s about control.

Most slow apps have the same problems:

  • too many unnecessary renders
  • heavy work inside render
  • no control over when code runs
  • loading everything upfront

Fix those — and your app feels fast.

Not because React changed.

Because you stopped doing extra work.

Top comments (0)