How to search for a string in a nested array of objects and keep the nested structure
Introduction
Let’s imagine that we have a navigation, composed of 3(or more) levels of nesting. Our task is to add an inline search, allowing the user to search for a keyword in the navigation but, at the same time, we need to keep displaying the hierarchical structure of the menu, so that the results are displayed by keeping the nested structure. So if we have 3 levels – parent > child > sub-child
and the result is a sub-child, its parent levels are kept, like in the example below:
For this tutorial, I’ve used React, but the focus of this tutorial is the search function itself, not the tool used for building the UI. Here you will find the mock data that I’ve used for the demo.
First, let’s load the data from the json file, store it in a state variable and display the results in an unordered list:
import data from './data/data.json';
.....
function InlineSearchMenu() {
const [categoriesToShow, setCategoriesToShow] = useState(() => data);
......
return (
<div className="App">
<input type="text" placeholder="Enter keyword" onChange={handleSearch}/>
<ul>
{categoriesToShow.map(parentCategory => (
<li key={parentCategory.name}>
{parentCategory.name}
{parentCategory?.children && (
<ul>
{parentCategory.children.map(childCategory => (
<li key={childCategory.name}>
{childCategory.name}
{childCategory?.children && (
<ul>
{childCategory.children.map(subChildCategory => (
<li key={subChildCategory.name}>{subChildCategory.name}</li>
))}
</ul>
)}
</li>
))}
</ul>
)}
</li>
))}
</ul>
</div>
);
}
Search algorithm
Now that the data is displayed, let’s add the search functionality. First I’ve created handleSearch()
which is a simple input event handler and then getResults
() where the searching occurs :
const handleSearch = (e) => {
const keyword = e.target.value.toLowerCase();
const results = getResults(data, keyword);
setCategoriesToShow(results);
};
const getResults = (categories, keyword) => {
return categories.reduce((resultArray, category) => {
// Copy the category, in order to avoid mutating the argument directoy
const categoryCopy = {...category};
// If there is a match, push it to the resultArray.
if (category.name.toLowerCase().includes(keyword)) {
resultArray.push(categoryCopy);
// Else, if the category has children, search for match inside its children
} else if (category?.children) {
const subResults = getResults(category.children, keyword);
// If there are any matches in the children, push the whole category(from top level) to the results array,
// so that the array structure can be preserved
if (subResults.length) {
categoryCopy.children = subResults;
resultArray.push(categoryCopy);
}
}
return resultArray;
}, []);
};
In essence,I’m iterating through the array of objects and searcing for any matches on the current level. If there are no matches and the object has a children
property, I recursively iterate through it. Whenever there are matches, instead of adding the matched object to the array of results(resultArray
), I’m adding its parent and make sure that only the matching children are pushed. In this way, I’ve managed to display the matching results along with its parent categories and preserve the structure. Here is a demo of a keyword(Bee) which yields results from various categories, which are displayed along with their parent categories:
Some considerations
Keep in mind that this solution works well for smaller sets of data, but if you need to search through thousands of objects and multiple nesting levels, this may not be optimal. Actually, in that case, I wouldn’t even recommend having an inline search but rather handle the search on the server.
As a further optimization, you can add debouncing to avoid calling the recursive function on each new added character.
If you want to test this solution yourself, here is the Git repository with the project.