Skip to Main Content

A Front-End Web Development Blog by Kristen Grote.

Headless Craft CMS with GraphQL and React, Part 7: Adding Search Filtering

Watch a Video of this Tutorial

A static recipe listing page is a nice starting point for our website, but to make it really useful we want our users to be able to search for a specific recipe. In this tutorial we're going to add a React-managed search field that will query our Craft CMS backend using Craft's built-in search functionality. 

Add search and orderBy Variables to the GraphQL Query

Just like with our pagination we need to add some variables that will be passed to our GraphQL query to enable search filtering. Open up /data/recipe-entries-query.js and add the following:

const GET_RECIPE_ENTRIES = gql`
    query GetRecipeEntries(
        $section: [String]
        $limit: Int
        $offset: Int
        $search: String # <-- add this
        $orderBy: String # <-- add this
    ) {
        entries(
            section: $section
            limit: $limit
            offset: $offset
            search: $search # <-- add this
            orderBy: $orderBy # <-- add this
        ) {
            # ...
        }
        entryCount(
            section: $section
            search: $search # <-- add this
        )
    }
`;

First, we've added both a $search and an $orderBy variable because we want to sort our search results by score rather than date. Both variables accept a single string value, so that's what we set for the type. We also make sure to pass the search variable to the entryCount field so our pagination is accurate to the number of search results we receive. 

Add Key Arguments to the Apollo Cache

When Apollo caches data, it uses a storage key to know whether or not queried data has already been cached. The storage key uses our filter variables to know if sets of cached items should be grouped together. So any set of cached items with the key section: ['recipes'] will be returned when we query for section: ['recipes']

When we set up pagination on our site we added the offsetLimitPagination() rule to our query's cache policy. By default, that function tells Apollo to return cached items that match any filter variable. So say we query for { section: ['recipes'], search: 'garlic' }, Apollo will return all sets of cached items that match section: ['recipes'] OR search: 'garlic'

So when we first load our recipe website, Apollo queries for all entries that match section: ['recipes'] and stores them in a cache with the key { section: ['recipes'] }. Then when we submit a search query, Apollo stores those results in a cache with the key { section: ['recipes'], search: 'garlic' }. When it's time for Apollo to return the results to the user, it will return both caches because they both have the key section: ['recipes'], which means we'll get duplicate data stacked on top of each other. 

To prevent this, we need to tell Apollo to only return results when ALL filter variables are present. To do that we need to apply Key Arguments in our cache settings. Open up /data/query-cache.js and update with the following:

const listingKeyArgs = ['section', 'search'];

const queryCache = new InMemoryCache({
    typePolicies: {
        Query: {
            fields: {
                entries: offsetLimitPagination(listingKeyArgs),
                entryCount: {
                    keyArgs: listingKeyArgs
                }
            },
        },
    },
});

Now Apollo will group cached items together correctly and return just the results we need. You can see the cache keys at work in the Cache tab of Apollo Devtools:

Screenshot of Apollo Devtools cache tab showing the cache keys for entries and entryCount with the section variable set to "recipes"

Create an Apollo Reactive Variable to Manage Input State

Just like with any React application we want to use state to manage the value of our search input. However since the value of the input directly affects our Apollo useQuery hook, we're going to use special state-management variables provided by Apollo called Reactive Variables. They work similarly to native React state variables, but when they are updated they automatically trigger a refetch of our query. 

Let's create a new Reactive Variable on our homepage:

import { makeVar } from "@apollo/client";
import { useReactiveVar } from '@apollo/client/react';

const searchQuery = makeVar(undefined);

export default function Home() {
  //...
}

Note that, unlike React state variables, Apollo Reactive Variables are declared outside of the component. The var starts out as undefined until we give it a value via the search input. 

Next, we'll tell Apollo to watch for state changes on this variable and refetch the query with the useReactiveVar() function:

export default function Home() {
  const queryOffset = useRef(0);

  const queryVariables = {
    section: ['recipes'],
    limit: 4,
    offset: queryOffset.current,
    search: useReactiveVar(searchQuery) // add the reactive var as a query variable
  }

  const { error, data, fetchMore } = useQuery(GET_RECIPE_ENTRIES, { variables: queryVariables });

  // ...
}

Create a Search Input Handler Function

Now let's write a handler function to update the value of searchQuery when the search input value changes:

const handleSearchChange = (e) => {
    const inputValue = e.target.value;

    searchQuery(inputValue || undefined);
}

We want an empty value to be explicitly set as undefined, otherwise Craft will interpret an empty string as a search request and give us results in the wrong order. 

Pass the Reactive State and Handler Function to the Search Component

Now we pass the reactive variable and the handler function as props to our SearchFilter component.

/pages/index.js:

<SearchFilter
    inputValue={searchQuery}
    handleChange={handleSearchChange}
/>

/components/SearchFilter.js:

export default function SearchFilter({inputValue, handleChange}) {
    return (
        <div className="search-filter">
            <input 
                id="search" 
                name="search" 
                type="search" 
                value={inputValue() ?? ''}
                onChange={(e) => handleChange(e)} 
            />
        </div>
    );
}

We read the value of a Reactive Variable by calling it as a function, hence inputValue(). React controlled inputs can't have undefined as a value so we include a bit of logic to return an empty string instead.

Open the site in a browser and watch the query update automatically as you type into the search bar! (You did remember to set your recipe fields to searchable in Craft, right?) 

You can see the search variable being added to the query in the Apollo devtools:

Screenshot of Apollo devtools showing query variables including "search" with a value of "garlic"

Add Logic for Sorting and Offset

Things are looking good, but we need to add a little extra logic to get the search functionality working just right. First, we want to sort search results by score, not date. Second, we need to reset the offset variable when a search query is submitted so our results are returned from the beginning. 

We'll make orderBy a useRef and update it in our search handler function along with the offset value:

export default function Home() {
  const queryOffset = useRef(0);
  const queryOrderBy = useRef(undefined);

  const queryVariables = {
    section: ['recipes'],
    limit: 4,
    offset: queryOffset.current,
    search: useReactiveVar(searchQuery),
    orderBy: queryOrderBy.current
  }

  // ...

  const handleSearchChange = (e) => {
    const inputValue = e.target.value;

    searchQuery(inputValue || undefined);
    queryOrderBy.current = inputValue ? 'score' : undefined;
    queryOffset.current = 0;
  }

As with the search variable if the input value is empty we want to reset orderBy to undefined to return to default date sorting. 

Return a Message for No Results

Last, we want to return a message to the user if there were no results for their search query. Add the following after the null data check in /components/RecipeListing.js:

if (!data) return null;

// add this
if (!data.entries.length) {
  return <p>There are no recipes that match your filters.</p>;
}

Take it Further with Debouncing

We've successfully set up a handy search feature for our users to find the exact recipe they want. This works well as-is, but it could be even better. 

Currently, Apollo submits a refetch for every single letter typed into the search bar, which results in a lot of unnecessary requests to our Craft backend. For extra credit, try adding debouncing to the search input so the query is only refetched after the user has finished typing an entire phrase (Hint: you will need to split the input value and the submitted search query into separate state variables).

View the Project Code