How To Add Search Functionality to a NextJS Markdown Blog (Part 2)
#nextjs #react #tailwind #typescript
7 min read
NOTE: If you haven't done so yet, you'll want to first read How To Add Search Functionality to a NextJS Markdown Blog (Part 1).
So, picking up where I left off in Part 1, the next step was to figure out how I wanted the UI to look like. I've decided for now, to keep things simple and add a Search component to my blog list screen. You'll probably have seen it on your way here, but if not, check out /blog.
To summarise, here are the main features for this Search
component:
- An input field that allows a user to type in text.
- Upon clicking on the input field, the general list of blog posts links are hidden, so that...
- As the user types, a dynamic list of blog post results matching the search query is shown.
- When the user clicks on a result, they are taken to the blog post.
- When a user clicks outside of the input field, the general list of blog posts are shown again, and the input field search query is cleared.
Let's start with the Search
component itself, before looking at how to integrate it into my blog posts page.
Step 1: Create the basic HTML structure and styling
I use Tailwind to style my HTML and added some dark mode styling which you can choose to ignore below. The important things to note here are:
- The
useRef
hook which allows me to reference this entire component (more in the next step). - The setting up of various states for
active
,query
andresults
.active
will basically track whether a user is "actively" in search mode.results
is an array of blog posts that matches the searchquery
a user types in.
import { useCallback, useRef, useState } from 'react'
import CustomLink from '@/components/CustomLink'
import { CachedPost } from 'types'
export default function Search() {
const searchRef = useRef(null) as React.MutableRefObject<HTMLInputElement | null>
const [query, setQuery] = useState('')
const [active, setActive] = useState(false)
const [results, setResults] = useState<CachedPost[]>([])
const searchEndpoint = (query: string) => `/api/search?q=${query}`
return (
<div className="relative" ref={searchRef}>
<input
className="border-normal-text focus:outline-none border border-solid
box-border w-full rounded-lg
text-normal-text text-sm p-2
dark:border-off-white dark:bg-background-dark-mode dark:text-off-white"
placeholder="Search blog posts (this is a work in progress - pls report any bugs!)"
type="text"
value={query}
/>
{active && results.length > 0 && (
<ul
className="list-none overflow-hidden mt-2 p-2 absolute top-full inset-x-0 min-h-100px
bg-white dark:bg-background-dark-mode"
>
{results.map(({ frontMatter, slug }) => (
<li className="bg-white text-normal-text mt-2 leading-4 dark:bg-background-dark-mode last:mb-4" key={slug}>
<CustomLink href={`/blog/${slug}`} className="text-sm">
{frontMatter.title}
</CustomLink>
</li>
))}
</ul>
)}
</div>
)
}
Step 2: Define the input element behaviour
We now want to define how the input element behaves. To do this, we'll need to define what happens onChange
and onFocus
.
Looking at onFocus
, this callback function is called whenever the input element is in focus i.e. when a user clicks on it. In this case, I want the active
state to be true (i.e. show a list of results) and to add an onClick
listener which will allow us to define the behaviour we want to happen when a user next clicks on something (whether it's a blog post result, or out of the input element).
Considering onChange
now, this callback function is called whenever the value of the input element changes e.g. as a user types a search query. In this case, I want to grab the event.target.value
and set this as my query
state. I can then call my searchEndpoint
which calls my api (set up in Part 1) with the query
. If there are results, I set the results
state (an array of blog posts). As onChange
is called every time a user types or deletes a letter, the results
array is continuously updated making it dynamic.
The final thing to do here is to define what happens onClick
. What I'm saying here is that if the user clicks anywhere outside of the Search
component, we should make the active
state false because the user no longer wants to be in "search mode". To tidy things up, I also want to clear the search query and results array, whilst removing the onClick
listener since it has now been fulfilled.
Note that I wrapped the onClick
and onChange
functions with the useCallback
hook from React to try to prevent unnecessary re-renders.
import { useCallback, useRef, useState } from 'react'
import CustomLink from '@/components/CustomLink'
import { CachedPost } from 'types'
export default function Search() {
const searchRef = useRef(null) as React.MutableRefObject<HTMLInputElement | null>
const [query, setQuery] = useState('')
const [active, setActive] = useState(false)
const [results, setResults] = useState<CachedPost[]>([])
const searchEndpoint = (query: string) => `/api/search?q=${query}`
const onChange = useCallback((event) => {
const query = event.target.value
setQuery(query)
if (query.length) {
fetch(searchEndpoint(query))
.then((res) => res.json())
.then((res) => {
setResults(res.results)
})
} else {
setResults([])
}
}, [])
const onFocus = () => {
setActive(true)
window.addEventListener('click', onClick)
}
const onClick = useCallback((event) => {
if (searchRef.current && !searchRef.current.contains(event.target)) {
setActive(false)
setQuery('')
setResults([])
window.removeEventListener('click', onClick)
}
}, [])
return (
<div className="relative" ref={searchRef}>
<input
className="border-normal-text focus:outline-none border border-solid
box-border w-full rounded-lg
text-normal-text text-sm p-2
dark:border-off-white dark:bg-background-dark-mode dark:text-off-white"
onChange={onChange}
onFocus={onFocus}
placeholder="Search blog posts (this is a work in progress - pls report any bugs!)"
type="text"
value={query}
/>
{active && results.length > 0 && (
<ul
className="list-none overflow-hidden mt-2 p-2 absolute top-full inset-x-0 min-h-100px
bg-white dark:bg-background-dark-mode"
>
{results.map(({ frontMatter, slug }) => (
<li className="bg-white text-normal-text mt-2 leading-4 dark:bg-background-dark-mode last:mb-4" key={slug}>
<CustomLink href={`/blog/${slug}`} className="text-sm">
{frontMatter.title}
</CustomLink>
</li>
))}
</ul>
)}
</div>
)
}
Step 3: Incorporating Search component into its parent component
The final step is to incorporate our newly created Search
component into the Blog
page. As I mentioned above, I want to hide the general list of blog posts whenever "search mode" is activated, so to do this, I need my parent component to monitor some sort of searchActive
state
I then linked this to an onFocusHandler
prop on the Search
component.
export default function Blog({ posts, title, description }: BlogProps) {
const [searchActive, setSearchActive] = useState<boolean>(false)
const checkSearchStatus = (status: boolean) => {
if (status) {
setSearchActive(true)
} else {
setSearchActive(false)
}
}
return (
<SiteContainer title={title} description={description}>
<div className="mb-6">
<section>
// ...blah blah
<div className="mb-4">
<Search onFocusHandler={(status) => checkSearchStatus(status)} />
</div>
{!searchActive && <BlogPosts posts={posts} />}
</section>
</div>
</SiteContainer>
Going back to our Search
component then, we need to amend it to allow the acceptance of this onFocusHandler
. The way we communicate to the parent is through the onChange
and onClick
functions. If onChange
is being called, it means that the user is very much in search mode, hence why we set onFocusHandler(true)
. If a user clicks anywhere outside our Search
component, they are no longer in search mode and we set onFocusHandler(false)
.
// imports...
interface SearchProps {
onFocusHandler: (status: boolean) => void
}
export default function Search({ onFocusHandler }: SearchProps) {
// ...
const onChange = useCallback((event) => {
onFocusHandler(true)
const query = event.target.value
setQuery(query)
if (query.length) {
fetch(searchEndpoint(query))
.then((res) => res.json())
.then((res) => {
setResults(res.results)
})
} else {
setResults([])
}
}, [])
const onFocus = () => {
setActive(true)
window.addEventListener('click', onClick)
}
const onClick = useCallback((event) => {
onFocusHandler(true)
if (searchRef.current && !searchRef.current.contains(event.target)) {
setActive(false)
onFocusHandler(false)
setQuery('')
setResults([])
window.removeEventListener('click', onClick)
}
}, [])
return (
// blah blah
)
}
Go back to our parent then, you can see that we are effectively calling the checkSearchStatus
function with either true
or false
, which then shows or hides our general list of blog posts.
export default function Blog({ posts, title, description }: BlogProps) {
const [searchActive, setSearchActive] = useState<boolean>(false)
const checkSearchStatus = (status: boolean) => {
if (status) {
setSearchActive(true)
} else {
setSearchActive(false)
}
}
return (
<SiteContainer title={title} description={description}>
<div className="mb-6">
<section>
// ...blah blah
<div className="mb-4">
<Search onFocusHandler={(status) => checkSearchStatus(status)} />
</div>
{!searchActive && <BlogPosts posts={posts} />}
</section>
</div>
</SiteContainer>
And that's all there is to it! What do you all think? Any ways I could have simplified this or explained it better? Let me know on Instagram or Twitter @bionicjulia.