8 min read

Build a Category Search with React

Create a fast category search for your react blog.

ByAmir Ardalan
Like
Share on X

In this article we are not going to build the next Google. Instead, we will create a fast and lightweight blog post and category search using TypeScript and React.

For the sake of simplicity, this guide will cover functionality only. You can apply your own styling to the final result!

The Problem

You have built a React-based blog and you have a blog page that displays your posts. As your content continues to grow, you will end up with a very long list (or many pages) of blog content. It would be nice for users to be able to quickly search through the blog for relevant content or to find content they previously read and are seeking out again.

Features and Considerations

My blog has a simple category system, each post is tagged with a single category. I want to be able to search through the blog posts by title and by category. I'd also like to have a simple category navigation above the search input that is dynamically generated based upon the categories which exists on all published posts. The search will initially search by title. If a search query begins with a #, then the posts will be searched by category. Finally, if a category is found, we can continue to search post titles within that category (#code markup).

Create compareID.ts

This function will vary depending on the data structure of your blog posts. For my setup, each post has an object, and each object has a unique ID. I can use the sort method on my blog feed Array and then pass this compare function to the sort method. This tells the code to sort the object in my array based on the ID. Modify this for your own use. Check out the MDN Docs for further reading on sort().

typescript
1// compareID.ts 2 3// Sort Blog posts by ID 4type ID = { id: number } 5 6const compareID = (a: ID, b: ID) => { 7 if ( a.id < b.id ) return -1 8 if ( a.id > b.id ) return 1 9 return 0 10} 11 12export default compareID 13

Create BlogPostFilter.tsx

This is where the bulk of our code will go. This file will render our blog posts and initially sort them based on compareID.ts, then it will allow us to further filter post based on title and/or category.

We will start by importing {useState} from react and our compareID util that we just created:

tsx
1// BlogPostFilter.tsx 2 3import { useState } from 'react' 4import compareID from '@/utils/compareID' 5// We will eventually add a few more things here 6 7 8const BlogPostFilter = () => { 9 10 const [search, setSearch] = useState('') 11 12 // Most of our code will go here! 13 14} 15 16export default BlogPostFilter 17

Render Our Posts

Let's get the blog posts displaying, I already have a component BlogPost that renders the post title and link, publish date, reading time and teaser copy. My blog post data is coming from the {feed} prop which I am passing into this component. It contains a unique ID for each post.

tsx
1// BlogPostFilter.tsx 2 3import { useState } from 'react' 4import compareID from '@/utils/compareID' 5import BlogPost from '@/components/BlogPost' 6 7 8const BlogPostFilter = ({ feed }) => { 9 10 const [search, setSearch] = useState('') 11 12 const filteredPosts = feed 13 14 const RenderPosts: Function = () => { 15 if (filteredPosts.length > 0) { 16 return ( 17 filteredPosts.sort(compareID).reverse().map((post: Record<string, string>) => ( 18 <span key={post.id}> 19 <BlogPost post={post} /> 20 </span> 21 )) 22 ) 23 } else null 24 } 25 26 return ( 27 <> 28 <div className="post"> 29 <RenderPosts /> 30 </div> 31 </> 32 ) 33} 34 35export default BlogPostFilter 36

Adding the Search Input

tsx
1// BlogPostFilter.tsx 2 3... 4 <div> 5 <input 6 type="text" 7 placeholder="Search posts" 8 value={search} 9 aria-label="Search posts" 10 onChange={(e) => setSearch(e.target.value)} 11 /> 12 </div> 13 <div> 14 <RenderPosts /> 15 </div> 16... 17

Create a Category Navigation

tsx
1// BlogPostFilter.tsx 2 3... 4 return ( 5 <> 6 <nav> 7 <ul> 8 <li> 9 Categories: 10 </li> 11 <li> 12 <button 13 onClick={() => setSearch('')} 14 onKeyPress={() => setSearch('')} 15 className={search === '' ? "all active" : "all"} 16 aria-label="All" 17 > 18 All 19 </button> 20 </li> 21 {activeCategories.sort().map((category, index) => ( 22 <li key={index}> 23 <button 24 onClick={() => handleCategoryLink(category)} 25 onKeyPress={() => handleCategoryLink(category)} 26 className={search.split(' ')[0] === '#'+category ? "active" : ""} 27 aria-label={category} 28 > 29 {category} 30 </button> 31 </li> 32 ))} 33 </ul> 34 </nav> 35 36 <div> 37 <input 38 type="text" 39 placeholder="Search posts" 40 value={search} 41 aria-label="Search posts" 42 onChange={(e) => setSearch(e.target.value)} 43 /> 44 </div> 45 <div> 46 <RenderPosts /> 47 </div> 48 </> 49 ) 50} 51 52export default BlogPostFilter 53

Expand Upon RenderPosts Function

Let's add a category link above each blog post to give users a way to quickly filter all posts by that category. We will also add an error state with the ability to clear the search if no matches were found.

tsx
1// BlogPostFilter.tsx 2 3... 4 const RenderPosts: Function = () => { 5 if (filteredPosts.length > 0) { 6 return ( 7 filteredPosts.sort(compareID).reverse().map((post: Record<string, string>) => ( 8 <span key={post.id}> 9 <button 10 onClick={() => handleCategoryLink(post.category)} 11 onKeyPress={() => handleCategoryLink(post.category)} 12 aria-label={post.category} 13 > 14 {post.category} 15 </button> 16 <BlogPost post={post} /> 17 </span> 18 )) 19 ) 20 } else { 21 return ( 22 <span> 23 No posts found. 24 {feed.length > 0 ? 25 <button 26 onClick={() => setSearch('')} 27 onKeyPress={() => setSearch('')} 28 aria-label="Clear search" 29 > 30 {' '}Clear Search 31 </button> 32 : null} 33 </span> 34 ) 35 } 36 } 37... 38

Implementing the Search Functionality

This is the core of the search functionality. It will allow users to input a search term and we'll try to match our blog titles to their search. If they begin the search query with a # we will search instead by category. If a category is found and the user continues to search, it will further drill down posts within that specific category by once again searching by title.

Note that the filteredPosts variable we created earlier has been slightly refactored here.

tsx
1// BlogPostFilter.tsx 2 3const BlogPostFilter = ({ feed }) => { 4 5 const [search, setSearch] = useState('') 6 7 const handleCategoryLink = (category: string) => { 8 setSearch('#'+category) 9 window.scrollTo(0, 0) 10 } 11 12 const activeCategories: Array<string> = Array.from(new Set(feed.map((post: Record<string, object>) => { 13 return post.category 14 }))) 15 16 const searchResults = (search: String, feed: Array<object>) => { 17 const categorySearch = search[0] === '#' 18 const categoryMatch = activeCategories.indexOf(search.slice(1).split(' ')[0]) > -1 19 20 // if #, search categories 21 if (categorySearch) { 22 // if category matches categories, search titles with category 23 if (categoryMatch) { 24 return feed.filter((data: { category: string, title: string }) => 25 data?.category?.toLowerCase().includes(search.slice(1).toLowerCase().split(' ')[0]) && 26 data?.title?.toLowerCase().includes(search.replace(/#[a-z]+/, '').trim().toLowerCase())) 27 } 28 return feed.filter((data: { category: string }) => 29 data?.category?.toLowerCase().includes(search.slice(1).toLowerCase())) 30 } 31 // if no #, just search titles 32 return feed.filter((data: { title: string }) => 33 data?.title?.toLowerCase().includes(search.toLowerCase())) 34 } 35 36 const filteredPosts = searchResults(search, feed) 37... 38

Putting it All Together

tsx
1// BlogPostFilter.tsx 2 3import { useState } from 'react' 4import compareID from '@/utils/compareID' 5import BlogPost from '@/components/BlogPost' 6 7 8 9const BlogPostFilter = ({ feed }) => { 10 11 12 const [search, setSearch] = useState('') 13 14 const handleCategoryLink = (category: string) => { 15 setSearch('#'+category) 16 window.scrollTo(0, 0) 17 } 18 19 const activeCategories: Array<string> = Array.from(new Set(feed.map((post: Record<string, object>) => { 20 return post.category 21 }))) 22 23 const searchResults = (search: String, feed: Array<object>) => { 24 const categorySearch = search[0] === '#' 25 const categoryMatch = activeCategories.indexOf(search.slice(1).split(' ')[0]) > -1 26 27 // if #, search categories 28 if (categorySearch) { 29 // if category matches categories, search titles with category 30 if (categoryMatch) { 31 return feed.filter((data: { category: string, title: string }) => 32 data?.category?.toLowerCase().includes(search.slice(1).toLowerCase().split(' ')[0]) && 33 data?.title?.toLowerCase().includes(search.replace(/#[a-z]+/, '').trim().toLowerCase())) 34 } 35 return feed.filter((data: { category: string }) => 36 data?.category?.toLowerCase().includes(search.slice(1).toLowerCase())) 37 } 38 // if no #, just search titles 39 return feed.filter((data: { title: string }) => 40 data?.title?.toLowerCase().includes(search.toLowerCase())) 41 } 42 43 const filteredPosts = searchResults(search, feed) 44 45 const RenderPosts: Function = () => { 46 if (filteredPosts.length > 0) { 47 return ( 48 filteredPosts.sort(compareID).reverse().map((post: Record<string, string>) => ( 49 <span key={post.id}> 50 <button 51 onClick={() => handleCategoryLink(post.category)} 52 onKeyPress={() => handleCategoryLink(post.category)} 53 aria-label={post.category} 54 > 55 {post.category} 56 </button> 57 <BlogPost post={post} /> 58 </span> 59 )) 60 ) 61 } else { 62 return ( 63 <span> 64 No posts found. 65 {feed.length > 0 ? 66 <button 67 onClick={() => setSearch('')} 68 onKeyPress={() => setSearch('')} 69 aria-label="Clear search" 70 > 71 {' '}Clear Search 72 </button> 73 : null} 74 </span> 75 ) 76 } 77 } 78 79 return ( 80 <> 81 <nav> 82 <ul> 83 <li> 84 Categories: 85 </li> 86 <li> 87 <button 88 onClick={() => setSearch('')} 89 onKeyPress={() => setSearch('')} 90 className={search === '' ? "all active" : "all"} 91 aria-label="All" 92 > 93 All 94 </button> 95 </li> 96 {activeCategories.sort().map((category, index) => ( 97 <li key={index}> 98 <button 99 onClick={() => handleCategoryLink(category)} 100 onKeyPress={() => handleCategoryLink(category)} 101 className={search.split(' ')[0] === '#'+category ? "active" : ""} 102 aria-label={category} 103 > 104 {category} 105 </button> 106 </li> 107 ))} 108 </ul> 109 </nav> 110 111 <div> 112 <input 113 type="text" 114 placeholder="Search posts" 115 value={search} 116 aria-label="Search posts" 117 onChange={(e) => setSearch(e.target.value)} 118 /> 119 </div> 120 <div> 121 <RenderPosts /> 122 </div> 123 </> 124 ) 125} 126 127export default BlogPostFilter 128

You should now have a basic category search working using TypeScript and React! It will require a bit of styling and there are also some UX enhancements you can make like a "clear search" icon inside the input and also below filtered posts to make it easier for a user to reset the search, especially if they have scrolled down a page of filtered posts.


Acknowledgements

Big thank you to Ryan Chase for assisting with refactoring my search function to be cleaner. And shout out to XHXIAIEIN on GitHub for the excellent suggestion to allow searching within an already filtered category. This is an awesome improvement on my original search feature.

Did you enjoy this post?

Like
Design & Code © 2024 Amir Ardalan0 views