Build a Category Search with React
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.
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()
.
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:
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.
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
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
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.
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.
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
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.