Optimizing a React SPA Part 1: Collections, Loops, and Filters
Know Your Data
So I've been writing code in C# for the last 7 years or so, and this past year the decision was made to use Typescript and React for a new project. And we hit some surprise scalability issues pretty quickly. This article will largely assume you have an idea where the bottleneck is and I'll try to cover profiling tips/tricks in another article.
Typescript/JavaScript are different. And some of that is good. I like using falsy checks in place of my normal null checks, and I enjoy the ability to easily make a monster with the objects:
const a = {...s, Rank = s.Rank+1, Level = getLevel(s.User)
But some things are just close enough they feel less intuitive, like making a dictionary. How do you grab a specific item from a collection?
Where is .Where() and .Select()?
.Where in C# is effectively.filter() in Typescript.
C#'s .First() doesn't have an exact equivalent but .FirstOrDefault() does: .find()
.find() returns undefined if it doesn't find an object that satisfies the condition.
.map() takes the place of our .Select() and the following .ToList()/.ToArray().
Now from C# you probably know that arrays are generally cheaper to iterate over, but slower to grow (and require more ceremony), but in Typescript arrays can be pushed onto like a List in C#.
const arrayOfObjects = new Array(10000).fill(Math.random());
objArray.push(...arrayOfObjects);
So what kinds of "array-like" objects can we store data in?
Array: bare bones basic, but likely what you need for overall readability.
Map: The hashmap in Typescript similar to the Dictionary in C# with some interesting caveats.
Set: like the array, but every object is required to be unique.
Object: yes, the plain old value that you may associate with JSON more than a proper data type. If you deserialize an array from a C# API, you may find it shows up as an object like this:
{
"0": "Value0",
"1": "Value1",
"2": "Value2",
"3": "Value3",
"4": "Value4",
}
You can access the members as of each index is a property. And with a an interface specified, you can still have type safety:
interface ObjectDictionary {
[key: number]: string
}
While researching for this article, I came across a great site that details collections I didn't even know existed. I'll probably end up covering some of them if I end up using them, but you should check it out: collectionsjs.com
Reducing Loops
Often a .FirstOrDefault() is a reasonable way to find an item in a large dataset being pulled from a database. But Dictionaries are much faster for accessing an item you know the key for (Benchmark: jsbench.me/29l3kt2jgq/1) . So your goto may be the find. And in fact, trying to build a Map for short-lived or single use searches is too expensive (Benchmark: jsbench.me/29l3kt2jgq/2). Even a small array is costly in comparison to using find().
But for the sake of performance, regardless of whether the data you got came in from an API call or is being pulled from a local cache, if you are reusing it, keep it around. If you have a call bringing in 50,000 items for a dashboard or to precache for reporting, you should keep anything useful and reuse it. Larger datasets take significantly longer to loop over. And thats a good place to start optimizing. If you know you are going to constantly search the collection based on that key, you're probably better off accepting the slight hit to build the map, then use it as you repeatedly search for objects. The access is much faster and doesn't require looping over the collection and with a condition although it can be somewhat more limited.
Just remember the syntax for getting an item from a map by key is map.get(key)
not map[key]
.
An alternative you may read about is object/key access. instead of building a Map, you just make a new object and assign each key with a value. It's much faster than a dictionary (Benchmark: jsbench.me/29l3kt2jgq/6)
const obj = {};
for (let i = 0; i < arrayOfObjects.length; i++) {
obj[arrayOfObjects[i].Key] = arrayOfObjects[i]
}
obj[9999]
Funny thing about using find(), the item you want is always in the last place you look for it. That means that if the item you want is in a collection of thousands, you are going to run that lambda at least once and potentially thousands of times. And if you're going to run it twice, you may double that or more. (Benchmark: jsbench.me/29l3kt2jgq/5) As long as the lamda's you are using aren't too expensive consider combining the calls into a single lambda on a filter(). You'll be left with an array that you'll need to run find on, but this could save you from having to traverse the original collection.
arrayOfObjects.find(o => o.Key === 9999)
arrayOfObjects.find(o => o.Key === 9997)
arrayOfObjects.find(o => o.Key === 9998)
// could be faster combined
const objectsFiltered = arrayOfObjects.filter(o => o.Key === 9999 || o.Key === 9997 || o.Key === 9998)
// but you'll need to search the resulting array, although the find will only run 10,006 times instead of 30,000 times
objectsFiltered.find(o => o.Key === 9999)
objectsFiltered.find(o => o.Key === 9999)
objectsFiltered.find(o => o.Key === 9997)
Use State to Your Advantage
Caching intermediate results or partitioning your data can be a good strategy as well. If their are any common filters, such as only looking at active Orders after a certain date, filter down to that first, then you run filters looking into tax amount, total order amounts, sum of all orders, orders per customer, etc, and loop over the smaller dataset.
Look for any components that search collections or run loops on every render. Is it possible to cache the results in State? If I am only looking at orders since October of last year, I can filter those down once, store the result in state and then only search and loop over the cached state anytime I need to access it on future renders.
Use the Right Kind of Loop
There are a few ways to loop in Typescript/Javascript, but as in C# the humble for Loop is uglier and usually faster than most (Benchmark: jsbench.me/29l3kt2jgq/7).
for (let i = 0; i < arrayOfObjects.length; i++) {
// Do stuff here
}
For loops have the advantage of of being flexible as long as you can access the items by index. As long as you don't throw another allocation in there like const obj = arrayOfObjects[i];
.
if you can't or don't need to access by index, .forEach is probably you're next best bet since the performance varies more on the other methods.
arrayOfObjects.forEach((obj) =>{
//do stuff with obj
})
I reran the above benchmark many times and small changes had fairly drastic effects on performance though, so you should probably pick the one that's most readable and only approach the others when you feel the need to benchmark them and squeeze the every drop of speed out of it you can.
Know Your Data
Overall, you need to understand your dataset to make any meaningful improvements regardless of your platform. And by understanding the relationships between the data your component needs and where it gets it, you can make optimizations without ever looking at a profiler.