Avatar

Hola, I'm Julia.

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.

SearchComponent

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 and results. active will basically track whether a user is "actively" in search mode. results is an array of blog posts that matches the search query 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.

© 2016-2024 Julia Tan · Powered by Next JS.