Çağan Atan
9 Tips for Data-Heavy, Performant Frontend Development in React

9 Tips for Data-Heavy, Performant Frontend Development in React

Çağan Atan Çağan Atan
April 9, 2025
10 min read
Table of Contents

Hey friends 👋 Çağan here!

If you’ve ever worked on a frontend project that involves large datasets (think dashboards, visual analytics tools, or anything with thousands of rows or nodes) you know performance can quickly become an issue.

Over the past couple of years, working with data visualization libraries like D3.js, Sigma.js, and Material React Table, I’ve internalized a bunch of tips and lessons, and wanted to share them with you. So here are my top 9 things to keep in mind when working with data-visualization-heavy React apps to keep your UI fast, smooth, and easy to build.


1. Design With Performance in Mind From Day One

For this one, I want to start with an example.

I had this dashboard that used Vis.js as a graph visualization library. If you don’t know (as I didn’t back then), Vis.js uses the Canvas API for rendering. Which, for the most part, shouldn’t be a problem. A lot of data visualization libraries also use Canvas API.

Except when you have 5000 nodes and thousands more edges between them.

If you want to see how bad the performance was, you can check out this demo. Just type out ‘5000’ in the number of nodes. Also, keep in mind that my network visualization was not as simple as this demo.

I had to replace this heavily customized Vis.js component with another network visualization library, Sigma.js. Sigma.js uses WebGL instead of Canvas API, which for my case made tremendous difference as it’s rendering and physics pipeline is non-blocking. It also has a very nice React wrapper.

If you are also interested in seeing Sigma.js under heavy load, you can check out this demo. Click on “start layout” for physics performance.

The lesson that I am trying to portray with this story is this: Beforehand, if I researched and made sure that Vis.js complied with my requirements; I wouldn’t have to do all the work switching back. And the thing is that I was actually lucky enough that I was not deep enough in my project that I was able to switch back. You may not be.

Quite literally best thing you can do for performance is thinking ahead. The worst issues I had with performance happened because I didn’t think ahead. Assume the worst case. Also, your future self will thank you. Think about performance early, before problems arise.

❌ Incorrect:

  • Do everything as they come
  • Don’t make any plans

✅ Correct:

  • Think ahead: “What happens when this has 100K rows/nodes?”
  • Design your UI/UX to be scalable (e.g., think about filters, tabs, summaries instead of dumping all the data at once).
  • Talk to your backend team (or your future self) early about pagination, indexes, and filtering.

2. Avoid Unnecessary Renders

Super critical to understand and internalize. Always happens, and hard to keep it on check.

React re-renders can sneak up on you. Especially when you’re juggling a dozen interdependent components, super deep prop-drills that cause expensive re-renders, and state flying around in complete chaos (Speaking from experience 😥).

Profiling your app with the React DevTools profiler or even newer tools like Million.js can help catch components that are rendering more than they should. But profiling only helps you see the issue: State architecture is the real battleground.

useContext is not global state (and that’s okay)

Using React’s built-in Context API to pass down global data like user settings, themes, or language preferences is super convenient. But here’s the catch: every time context updates, every component that consumes it will re-render, even if they don’t care about the changed part.

❌ Bad example:

const AppContext = createContext();
const AppProvider = ({ children }) => {
const [largeData, setLargeData] = useState(generateBigData());
const [currentPage, setCurrentPage] = useState(0);
const [user] = useUser();
const [theme] = useTheme();
return (
<AppContext.Provider value={{largeData, setLargeData, currentPage, setCurrentPage, user, theme}}>
{children}
</AppContext.Provider>
);
};
const TableWrapper = () => {
const {largeData, currentPage, setCurrentPage} = useContext(TableDataContext);
return <Table data={largeData} page={currentPage} setPage={setCurrentPage}/>
}
const OtherComponent = () => {
const {user, theme} = useContext(AppContext);
return (
<h1 style={{...theme}}>
Hello {user}!
</h1>
)
}
const App = () => {
return (
<AppProvider>
<OtherComponent/>
<TableWrapper>
<TableWrapper>
</AppProvider>
)
}

In this example, <OtherComponent/> will re-render every time largeData or currentPage changes even though it does not actually use those props.

✅ Better example (only components using largeData or currentPage re-render):

const TableDataContext = createContext();
const AppContext = createContext();
const TableDataProvider = ({ children }) => {
const [largeData, setLargeData] = useState(generateBigData());
const [currentPage, setCurrentPage] = useState(0);
return (
<TableDataContext.Provider value={{largeData, setLargeData, currentPage, setCurrentPage}}>
{children}
</TableDataContext.Provider>
);
};
const AppProvider = ({ children }) => {
const [user] = useUser();
const [theme] = useTheme();
return (
<AppContext.Provider value={{user, theme}}>
{children}
</AppContext.Provider>
);
};
const TableWrapper = () => {
const {largeData, currentPage, setCurrentPage} = useContext(TableDataContext);
return <Table data={largeData} page={currentPage} setPage={setCurrentPage}/>
}
const OtherComponent = () => {
const {user, theme} = useContext(AppContext);
return (
<h1 style={{...theme}}>
Hello {user}!
</h1>
)
}
const App = () => {
return (
<AppProvider>
<OtherComponent/>
<TableDataProvider>
<TableWrapper>
<TableWrapper>
</TableDataProvider>
</AppProvider>
)
}

✅ Use context for rarely changing, truly global state

❌ Avoid stuffing large, frequently-updating data (like a huge list) in context

State Management Libraries: Use them, wisely

Libraries like Zustand, Jotai, or Redux offer fine-grained control over state updates.

For example, Zustand allows selective subscriptions to specific slices of state, meaning components won’t re-render unless that particular slice changes.

With Zustand:

const useStore = create((set) => ({
data: [],
largeData: null,
setLargeData: (data) => set({ largeData: data }),
}));
// This component will only re-render if largeData changes
const TableWrapper = () => {
const largeData = useStore((state) => state.selectedItem);
return <Table data={largeData}/>;
};

Or, just say fuck it and survive until React Compiler comes out on stable. Hopefully it will put a bandaid on your spagetti code. Oh wait, you are stuck with React 16? Too bad.

3. Paginate or Virtualize Large Lists and Tables

Don’t render 100,000 items at once. Your browser and your users will thank you.

“So, how will I show my list/table containing 100,000 users then?”, I can hear you ask.

My advice:

❌ Incorrect:

return (
<div>
{bigList.map(item => (
<div key={item.id}>{item.name}</div>
))}
</div>
);

✅ Correct:

import { useVirtualizer } from '@tanstack/react-virtual';
const VirtualList = ({ bigList }) => {
const parentRef = useRef();
const rowVirtualizer = useVirtualizer({
count: bigList.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 35,
});
return (
<div ref={parentRef} style={{ height: '400px', overflow: 'auto' }}>
<div style={{ height: rowVirtualizer.getTotalSize(), position: 'relative' }}>
{rowVirtualizer.getVirtualItems().map(virtualRow => {
const item = bigList[virtualRow.index];
return (
<div
key={item.id}
style={{
position: 'absolute',
top: 0,
left: 0,
height: 35,
transform: `translateY(${virtualRow.start}px)`,
}}
>
{item.name}
</div>
);
})}
</div>
</div>
);
};

4. Flatten and Normalize Your Data

Deeply nested objects are hard to manage and update efficiently. Flattening makes data manipulation and rendering easier.

This process is particularly useful when you’re working with large datasets, as it allows for better performance and easier updates.

Think about it like this: JavaScript objects are basically maps. Let’s assume you are trying to access an element 3 objects deep. You have to do 3 lookup operations just to access this data. If your object were to be flat, you would be able to access this data in a single lookup.

Also, keeping nested objects/arrays are very memory inefficient, especially for JavaScript.

❌ Incorrect:

const user = {
id: '1',
profile: {
name: 'Jane',
address: {
city: 'Istanbul'
}
}
};
const city = user.profile.address.city;

✅ Correct:

const normalizedUser = {
id: '1',
name: 'Jane',
city: 'Istanbul'
};
const city = normalizedUser.city;

5. Debounce or Throttle Expensive Updates

Real-time input can bottleneck performance. Debounce user input when filtering or processing data.

This tip is also super critical if there is some kind of API call being made with real-time input, such as a search suggestion or filtered table data.

❌ Incorrect:

<input onChange={(e) => filterData(e.target.value)} />

✅ Correct:

import debounce from 'lodash.debounce';
const debouncedFilter = useMemo(() => debounce(filterData, 300), []);
<input onChange={(e) => debouncedFilter(e.target.value)} />

6. Use Web Workers for Heavy Computation

If you remember (or have read and not skipped it) from the first section, I talked about how I solved my performance issue about Vis.js by switching to another library. The following tip, web workers, was one of the main reasons why I did so.

Sigma.js uses Graphology, which in turn uses Web Workers for physics simulation. This kept rest of the app responsive even if there were tremendous amounts of nodes on the graph.

Heavy calculations on the main thread block the UI. Offload to a Web Worker to keep the app responsive.

❌ Incorrect:

const result = heavyCalculation(data); // UI freezes

✅ Correct:

worker.js
self.onmessage = (e) => {
const result = heavyCalculation(e.data);
self.postMessage(result);
};
// In component
// ...
const worker = new Worker(new URL('./worker.js', import.meta.url));
worker.postMessage(data);
worker.onmessage = (e) => setResult(e.data);
// ...

7. Don’t Abuse State

Probably one of the easiest to understand/implement of these tips.

Don’t get me wrong, the WYSIWYG principle of React is really nice. But, not everything needs to live in state. Use refs or memoized values for static data or computed values.

If you are confused about when to use refs and when to use state, check out this post from React team.

❌ Incorrect:

const [items, setItems] = useState(bigDataArray); // huge array in state

✅ Correct:

const itemsRef = useRef(bigDataArray); // store in ref if no reactivity needed
const items = useMemo(() => processData(bigDataArray), [bigDataArray]);

8. Keep an Eye on Bundle Size

Import only what you use. Large libraries can bloat your app and hurt load times.

This tip might be redundant depending on how smart your build tool or how critical the loading time is. Still, a little extra optimization never hurts.

❌ Incorrect:

import * as d3 from 'd3'; // everything, even unused modules

✅ Correct:

import { scaleLinear } from 'd3-scale';
import { select } from 'd3-selection';

9. Profile, Profile, Profile

Guessing where the slowdown is? Don’t. Use profiling tools to find the real bottlenecks.

Profiling will not only speed up your application as is, it will reveal future bottlenecks before they even emerge and when they are easier to fix.

❌ Incorrect:

  • “It seems fast enough to me.” 🙈

✅ Correct:


Wrap-Up

Every one of these performance tips comes from personal experiences where either I hit a wall and had to take a step back and think about things; or after realizing my mistake, when I had to rewrite A LOT of my code for performance fixes.

If you’re building data-heavy UIs in React, I would heavily suggest keeping these in mind, as it will absolutely save your app and your sanity.

Happy shipping! 🛳️✨

Çağan