Data Structures In Frontend JavaScript In The Real WorldWith React Code Examples

Johannes KettmannPublished on 

Data structures can be intimidating. Especially for the self-taught folks among us. If you already tried to study data structures in JavaScript you know what typically happens:

  1. A bunch of CS theory is thrown at you.
  2. You read pages of code that manually implement e.g. a linked list.
  3. All the examples are abstract using foo and bar or cats and dogs.

On top of that, you’re confronted with smart-looking diagrams like this:

But if you’ve been around for a while you may say “I’ve never seen this in my frontend projects!” And you’re right. I haven’t either.

At the same time, you certainly use some data structures already. Not as obvious as new HashMap() but disguised as simple objects or arrays. And if you don’t, this is a good opportunity to get started.

On this page, we’ll

  • answer the question if you should study data structures and when they can be helpful.
  • look at a few common data structures that you can find in real-world frontend projects.

No worries, you won’t find full-blown implementations of any data structure here. There are plenty of resources that cover this. But you will see examples that you might recognize from your own code or that can use in your projects or on the job.

Do you have to study data structures as a frontend JavaScript developer?

This question is likely to trigger a debate. Here are the two extremes:

  • “Yes. Data structures are essential for any programmer no matter what your tech stack is.”
  • “To me all that data structure stuff is theory crap and in my 13+ yrs as a web developer, I have never really seen a case where this stuff would be needed.”

For a lot of devs (especially the self-taught among us), the second answer is much more comforting. And it’s true. At least partly. Here are a few reasons why you won’t find many advanced or formal data structures in JavaScript frontends:

  • Data sets on the frontend typically aren’t large enough to see the benefits of advanced data structures.
  • JavaScript doesn’t even support many data structures natively.
  • React (as the most widely spread frontend framework) doesn’t work well with custom data structures since it requires immutable state updates. Cloning a custom data structure would likely eat up any performance benefits.

But as I said, the “you don’t need data structures on the frontend” answer is only partly true.

  1. You might need to know data structures for technical interviews (emphasis on might).
  2. More importantly, a common problem among frontend devs that leads to really messy code is… picking the wrong data structure. Here is an example.

Selecting a data structure can be the make-or-break decision of your code’s quality. It decides what the rendering logic looks like or how you update the data. So it really can be helpful to know a few options.

Map

Description

A (hash) map, hash table, or dictionary is basically a key-value storage. In JavaScript we actually use this all the time:

{
key1: "value 1",
key2: "value 2",
key3: "value 3",
}

We also have a native JavaScript alternative in form of a Map. It has a few handy methods and properties (such as Map.size), is optimized performance-wise for accessing values by key, and allows any kind of type as keys (even objects).

Another advantage of the Map: with objects the keys are converted to strings as shown below. A Map on the other hand preserves the original type.

const numericKeys = {
1: "value-1",
2: "value-2",
}
console.log(Object.keys(numericKeys))
// ["1", "2"]

Real-World Example: Messages with user names

In the wild, you can find maps when two data sets reference each other. As in this chat component.

We have two arrays (messages and users). Each message has a reference to the author via its userId field.

const messages = [
{
id: "message-1",
text: "Hey folks!",
userId: "user-1"
},
{
id: "message-2",
text: "Hi",
userId: "user-2"
},
...
];
const users = [
{
id: "user-1",
name: "Paul"
},
{
id: "user-2",
name: "Lisa"
},
...
];

We could find the corresponding user when we render each message.

messages.map(({ id, text, userId }) => {
const name = users.find((user) => user.id === userId).name;
return ... // return JSX
}

But that’s not ideal performance-wise as we have to iterate over the users array for each message. Instead, we can use a map.

Let’s start with a simple key-value object. The goal is to create an object like this:

{
"user-1": "Paul",
"user-2": "Lisa",
...
}

We can achieve this with the Array.reduce() function (find the complete code here).

const messages = [
{
id: "message-1",
text: "Hey folks!",
userId: "user-1"
},
...
];
const users = [
{
id: "user-1",
name: "Paul"
},
...
];
function ChatUsingMap() {
const namesById = users.reduce(
(prev, user) => ({ ...prev, [user.id]: user.name }),
{}
);
return messages.map(({ id, text, userId }) => (
<div key={id}>
<div>{text}</div>
<div>{namesById[userId]}</div>
</div>
));
}

Alternatively, we can use a real Map. The constructor accepts an array of key-value pairs, so we can get rid of the reduce function (here is the code on CodeSandbox).

function ChatUsingMap() {
const namesById = new Map(users.map(({ id, name }) => [id, name]));
return messages.map(({ id, text, userId }) => (
<div key={id}>
<div>{text}</div>
<div>{namesById.get(userId)}</div>
</div>
));
}

For clarity: we pass an array of arrays like this to the Map constructor:

[
["user-1", "Paul"],
["user-2", "Lisa"],
...
]
You don't feel "job-ready" yet?
Working on a full-scale production React app is so different from personal projects. Especially without professional experience.
Believe me! I've been there. That's why I created a program that exposes you to
  • a production-grade code base
  • realistic tasks & workflows
  • high-end tooling setup
  • professional designs.

Set

Description

A Set is a keyed collection as well that is natively supported in JavaScript. But while it’s easier to think of a Map as an object, a Set is more like an array. It’s important to note that each value in a Set is unique. You can’t add a value twice. That’s why you may see a Set used to remove duplicates from an array.

const uniqueValues = [...new Set([1, 1, 2, 3, 3])];
// [1, 2, 3]

Compared to an array, the main advantage is that you can check if a Set contains a value in a performant way.

Real-World Example: Keeping track of selected items

In the wild, you can find a set when tracking items selected by a user. Like in this table:

When the user selects an item we keep track of it by adding its id to a set. When the user unselects we remove it from the set again (here is the code on CodeSandbox).

import { useState } from "react";
const rows = [
{
id: "id-1",
name: "Row 1",
},
{
id: "id-2",
name: "Row 2",
},
...
];
function TableUsingSet() {
const [selectedIds, setSelectedIds] = useState(new Set());
const handleOnChange = (id) => {
const updatedIdToSelected = new Set(selectedIds);
if (updatedIdToSelected.has(id)) {
updatedIdToSelected.delete(id);
} else {
updatedIdToSelected.add(id);
}
setSelectedIds(updatedIdToSelected);
};
return (
<table>
<tbody>
{rows.map(({ id, name }) => (
<tr key={id}>
<td>
<input
type="checkbox"
checked={selectedIds.has(id)}
onChange={() => handleOnChange(id)}
/>
</td>
<td>{id}</td>
<td>{name}</td>
</tr>
))}
</tbody>
</table>
);
}

A final note: using a Set can be a great alternative to using an array. A problematic implementation of the “select items” functionality that you can often see looks something like this:

const [selectedRows, setSelectedRows] = useState(
rows.map((row) => ({ selected: false }))
);
const handleOnChange = (index) => {
const updatedSelectedRows = [...selectedRows];
updatedSelectedRows[index] = !updatedSelectedRows[index];
setSelectedRows(updatedSelectedRows);
};

This might look straightforward at first. But accessing the elements via the index can cause problems. Just one example: what happens if the rows can be sorted in a different order? We’d have to keep the selectedRows array in sync and sort it the same way.

If you want to see how migrating to a Set can lead to much cleaner and reduced code check this refactoring session.

Stack

Description

A basic stack has two features:

  • You can add an item to the top of the stack.
  • You can remove an item from the top of the stack.

This is called “Last in, first out” (aka LIFO). Might sound complicated but we can implement it with a simple array (as it’s not natively supported in JavaScript).

const stack = [];
// add item to the "top"
stack.push("value");
// remove item from the "top"
const topItem = stack.pop();

Real-World Example: Undo previous actions

In the wild, you can find a stack when you need to keep track of the history of user interactions to build an undo functionality. Like in this table where the user can remove each row.

Whenever a user deletes a row from the table we add it to a history array (our stack). When the user wants to undo the deletion we get the latest row from the history and re-add it to the table. Some of the functions aren’t shown below but you can find the complete code here on CodeSandbox.

Note that we can’t just use Array.push() and Array.pop() as React requires immutable data. Instead, we use Array.concat() and Array.slice() because both return new arrays.

import { useReducer } from "react";
const rows = [
{
id: "id-1",
name: "Row 1",
},
{
id: "id-2",
name: "Row 2",
},
...
];
// "history" is our stack
const initialState = { rows, history: [] };
function reducer(state, action) {
switch (action.type) {
case "remove":
return {
rows: removeRow(state, action),
// Array.concat() as immutable alternative to Array.push()
history: state.history.concat({
action,
row: state.rows[action.index]
})
};
case "undo":
return {
rows: addRowAtOriginalIndex(state),
// Array.slice() as immutable alternative to Array.pope()
history: state.history.slice(0, -1)
};
default:
throw new Error();
}
}
function TableUsingStack() {
const [state, dispatch] = useReducer(reducer, initialState);
return (
<>
<button
onClick={() => dispatch({ type: "undo" })}
disabled={state.history.length === 0}
>
Undo Last Action
</button>
<table>
<thead>
<tr>
<th>ID</th>
<th>Name</th>
<th></th>
</tr>
</thead>
<tbody>
{state.rows.map(({ id, name }, index) => (
<tr key={id}>
<td>{id}</td>
<td>{name}</td>
<td>
<button onClick={() => dispatch({ type: "remove", id, index })}>
Delete
</button>
</td>
</tr>
))}
</tbody>
</table>
</>
);
}

As mentioned in the comments, history is our stack. When a user clicks one of the “Remove” buttons we add the action plus some additional data to the history. The “Undo Last Action” button then removes the latest action from the history stack and adds the row again to the data.

You don't feel "job-ready" yet?
Working on a full-scale production React app is so different from personal projects. Especially without professional experience.
Believe me! I've been there. That's why I created a program that exposes you to
  • a production-grade code base
  • realistic tasks & workflows
  • high-end tooling setup
  • professional designs.

Queue

Description

A queue is very similar to a stack. But instead of removing the element that was added last, we remove the element that was first added to the queue. This is called “First in, first out” (aka FIFO).

Like a queue in the supermarket.

We can implement it again using a simple array as it's not natively supported in JavaScript.

const queueu = [];
// add item to the "end"
queueu.push("value");
// remove item from the "beginning"
const firstItem = queueu.shift();

Real-World Example: Notifications

In the wild, you can find a queue when you have show notifications on top of each other. You know, the kind of notification that pops up at the bottom of the screen and disappears after a while.

In this case, a notification pops up whenever a user joins our imaginary community.

Whenever we click the button a message is added to the notifications array (our queue). Additionally, a timeout is set that removes the first notification in the queue again. You can find the complete code here on CodeSandbox.

import { faker } from "@faker-js/faker";
import { useState } from "react";
function ButtonAddingNotifications() {
const [notifications, setNotifications] = useState([]);
const addNotification = () => {
setNotifications((previous) => {
// use Array.concat() as immutable alternative to Array.push()
return previous.concat(`${faker.name.firstName()} joined!`);
});
setTimeout(() => {
// use Array.slice() as immutable alternative to Array.shift()
setNotifications((previous) => previous.slice(1));
}, 1000);
};
return (
<>
<button onClick={() => addNotification()}>
Invite User To Community
</button>
<aside>
{notifications.map((message, index) => (
<p key={index}>{message}</p>
))}
</aside>
</>
);
}

Tree

Description

A tree is a nested data structure. It starts with a parent that has children. The children again can have children of their own and so on. Together with trees you often find recursive functions.

Here is one example using a nested object:

{
name: "Parent",
children: [
{
name: "Child 1",
},
{
name: "Child 2",
children: [
{
name: "Grandchild 21",
}
]
}
]
}

There are many different ways of defining a tree. As an alternative to the above structure, we could also use a flat array. In that case, all elements need an ID and reference each other.

[
{
id: "parent-1",
name: "Parent",
// references to children via their IDs
children: ["child-1", "child-2"]
},
{
id: "child-1",
// reference to the parent vid its ID
name: "Child 1",
parent: "parent-1"
},
{
id: "child-2",
name: "Child 2",
parent: "parent-1",
children: ["grandchild-2"]
},
{
id: "grandchild-21",
name: "Grandchild 21",
parent: "child-2"
}
]

The weird thing from a human perspective is that each child can only have one parent. That’s why you can also often find the terms “node” (aka any of the items), “child”, and “edge” (the connection between two items).

Real-World Example: Nested menus or comments

As mentioned, trees are nested data structures. And you can find those when you have nested menus that are defined on the backend (e.g. in a CMS).

Another common example is nested comments as you can find them on many blogs or Reddit. This component recursively renders a nested tree structure consisting (the complete code on CodeSandbox).

const menuItems = [
{
text: "Menu 1",
children: [
{
text: "Menu 1 1",
href: "#11",
},
{
text: "Menu 1 2",
href: "#12",
},
],
},
{
text: "Menu 2",
href: "#2",
},
{
text: "Menu 3",
children: [
{
text: "Menu 3 1",
children: [
{
id: "311",
text: "Menu 3 1 1",
href: "#311",
},
],
},
],
},
];
// Menu and MenuItem are recursively calling each other
function Menu({ items }) {
return (
<ul>
{items.map((item, index) => (
<MenuItem key={index} {...item} />
))}
</ul>
);
}
function MenuItem({ text, href, children }) {
// the break condition:
// stop recursion if the item doesn't have children
if (!children) {
return (
<li>
<a href={href}>{text}</a>
</li>
);
}
// recursively create a submenu
return (
<li>
{text}
<Menu items={children} />
</li>
);
}
// the root component
function NestedMenu() {
return <Menu items={menuItems} />;
}

The nested tree structure above is quite easy to read for humans and also simple to render. But if we need to update the data we quickly run into problems. Especially when we need to change the data immutably (as we have to with React state).

In this case, a great alternative is a flat array instead of the nested objects above. This is a bit heavier on the frontend logic as we have to resolve the ID references. But it’s very easy to interact with and probably also easier to handle for the backend.

const menuItems = [
{
id: "1",
text: "Menu 1",
children: ["11", "12"],
isRoot: true,
},
{
id: "11",
text: "Menu 1 1",
href: "#11",
},
{
id: "12",
text: "Menu 1 2",
href: "#12",
},
{
id: "2",
text: "Menu 2",
href: "#2",
isRoot: true,
},
{
id: "3",
text: "Menu 3",
children: ["31"],
isRoot: true,
},
{
id: "31",
text: "Menu 3 1",
children: ["311"],
},
{
id: "311",
text: "Menu 3 1 1",
href: "#311",
},
];
function Menu({ itemIds, itemsById }) {
return (
<ul>
{itemIds.map((id) => (
<MenuItem key={id} itemId={id} itemsById={itemsById} />
))}
</ul>
);
}
function MenuItem({ itemId, itemsById }) {
const item = itemsById[itemId];
if (!item.children) {
return (
<li>
<a href={item.href}>{item.text}</a>
</li>
);
}
return (
<li>
{item.text}
<Menu itemIds={item.children} itemsById={itemsById} />
</li>
);
}
function NestedMenu() {
const itemsById = menuItems.reduce(
(prev, item) => ({ ...prev, [item.id]: item }),
{}
);
const rootIds = menuItems.filter(({ isRoot }) => isRoot).map(({ id }) => id);
return <Menu itemIds={rootIds} itemsById={itemsById} />;
}

Note that recursive functions are very elegant but for very deeply nested trees you might exceed the JavaScript engine’s call stack size. Here is an example that throws an error at a recursion depth of 10962:

In our case, we won’t run into this problem as we won’t nest thousands of menu items.

You don't feel "job-ready" yet?
Working on a full-scale production React app is so different from personal projects. Especially without professional experience.
Believe me! I've been there. That's why I created a program that exposes you to
  • a production-grade code base
  • realistic tasks & workflows
  • high-end tooling setup
  • professional designs.