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:
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
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.
This question is likely to trigger a debate. Here are the two extremes:
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:
But as I said, the “you don’t need data structures on the frontend” answer is only partly true.
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.
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"]
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"],...]
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.
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><inputtype="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.
A basic stack has two features:
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();
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()
andArray.pop()
as React requires immutable data. Instead, we useArray.concat()
andArray.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 stackconst 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 (<><buttononClick={() => 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.
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();
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></>);}
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 IDschildren: ["child-1", "child-2"]},{id: "child-1",// reference to the parent vid its IDname: "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).
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 otherfunction 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 childrenif (!children) {return (<li><a href={href}>{text}</a></li>);}// recursively create a submenureturn (<li>{text}<Menu items={children} /></li>);}// the root componentfunction 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.