Code-Along: Implementing 'Search By Criteria' Functionality with React, Formik, and Flask-SQLAlchemy

Bianca Aspin - Apr 12 '23 - - Dev Community

My Current Project

As I reach the end of my journey as a bootcamp student, for my capstone project, I'm building an app that will allow users to search for national parks with particular accessibility-related amenities available. For today's blog, I wanted to walk through how I implemented a central piece of functionality for the app: the search. More specifically, the ability to search using multiple criteria.

The Problem I'm Trying to Solve

The search was at the heart of why I had the idea to create this app in the first place. In the official National Park Service mobile app, a user can search for parks to explore based on:

  • their proximity to the park
  • the name of the park
  • the state the park is located in
  • activities available at the park
  • topics discussed at the park
  • the type of park (e.g., park, monument, recreation area)

Screenshot from the official National Park Service mobile app showing the various filters available

That's a lot of search options! However, despite there being a wide range of accessible offerings at the parks, there currently isn't an easy way for users to find parks based on the accessibility offerings available. A user has to click into each park, navigate to the amenities section, and from there, find the relevant section on accessibility.

The National Park Service website provides a map of accessibility in the national parks, showing the location of all parks in the United States with accessible features and services. However, clicking on each location only provides a link to the park's accessibility page. There's no way to see at a glance which amenities are available.

Screenshot of the 'Accessibility' map on the official National Parks website, showing a universal wheelchair icon at each park location with accessible features, and a tooltip directing users to the corresponding park's accessibility page

There's no way to know whether "accessibility" at a given park means having wheelchair-accessible trails, having a sign-language interpreter for guided tours, or having tactile exhibits for those with visual impairments. The user has to manually search each page to whittle down which ones have the amenities they need to make the most of their trip, which is needlessly time-consuming.

My Vision for the Search Function

When I first sketched out what I wanted the user interface to look like, I knew I wanted the homepage to be a list of the various accessibility and accessibility-adjacent (e.g., cellular signal, access to groceries, access to drinking water) amenities available at the parks. I wanted users to be able to select (by clicking the corresponding checkbox) any of the criteria that would help them decide where to go, click 'Search,' and have a list of parks returned to them matching all the criteria they selected.

My initial sketch of the search page design, with a header at the top, three columns of checkboxes, and a search button at the bottom

Let's code along together to make that vision a reality!

Create 'Search' and 'Results' Components

We'll start by creating two components to house the search and results, respectively, and importing those into our main app to add them to the component tree. At the same time, let's add some basic routing by importing Switch and Route from React Router. (Note: I'm using version 5 of React Router here.)

// App.js

// React Imports
import { React } from 'react';
import { Switch, Route } from 'react-router-dom';

// Component Imports
// Note: This import reference will vary based on your file structure. In this case, the main App is stored outside the 'Components' folder.
import { Search } from './Components/Search';
import { Results } from './Components/Results';

function App() {

    return (
    <Switch>
        <Route path='/search'>
            <Search />
        </Route>
        <Route path='/results'>
            <Results />
        </Route>
    </Switch>
    )
};

export default App;
Enter fullscreen mode Exit fullscreen mode

Create the Initial Form

Next, in the search component, let's import React's useState and useEffect hooks. We'll put those to work right away, fetching the amenity data from the server from within useEffect with an empty dependency array so that the GET request only gets sent when the component initially renders, not continuously. Once that data is successfully retrieved, we'll save this array of amenity objects as a state variable.

With the amenities array saved in state, we can then iterate through using the map() method. Cycling through each amenity object in the array, we'll map its properties onto the attributes of an <input> element. The end result of the map will be a new array of checkbox elements that can then be added within the overall <form> being returned by the Search component.

Here's what the user interface should look like so far (without adding styling, for simplicity):

Screenshot of basic search page, a single column list of checkboxes for all amenities available

Here's what the code should look like so far:

// Search.js

// Importing React hooks
import { useState, useEffect } from 'react';

function Search() {

    // Setting state for amenities
    const [amenities, setAmenities] = useState([])

    // Fetching amenities from within useEffect
    useEffect( () => {
        fetch('/amenities')
            .then(response => response.json())
            .then(amenityData => setAmenities(amenityData))
    }, [])

    // Iterating through amenities to create an array 'checkbox' input elements.
    const checkboxes = amenities.map( (amenity) => {
        return (
            <div key={amenity.id}>
            <label>
                <input
                    type="checkbox"
                    name="checked"
                    value={amenity.id}
                />
                {amenity.name}
            </label>
            <br />
            </div>
        )
    })

    return (
        <div>
            <h1>Search</h1>
            <form>
                // Inserting that array of checkboxes into the form.
                {checkboxes}
                <input type="submit" value="Search" />
            </form>
        </div>
        )
    };

export default Search;
Enter fullscreen mode Exit fullscreen mode

One thing that you'll notice looking at the code above is that each of the checkbox elements has the same name, "checked". This is by design, as we'll cover in the next section.

Configure Formik

Formik is an incredibly useful React library for managing state within our forms. Setting up the useFormik hook and using its built-in state and change handlers takes out a lot of the work that comes with setting up form fields as controlled components.

// Search.js

// Other React imports

// Importing Formik
import { useFormik } from 'formik';

function Search() {

    // State-setting, useEffect fetching, etc.

    // Initializing the useFormik hook
    const formik = useFormik({
        initialValues: {
            checked: []
        },
        onSubmit: (values) => {
            console.log(values)
        }
    })

    // checkboxes, return, etc.
};
Enter fullscreen mode Exit fullscreen mode

In the snippet of code above, we're initializing the useFormik hook. We're only specifiying one field here, 'checked' — corresponding with the name attribute that we assigned to each of the checkbox elements earlier — and setting it equal to an empty array for its initial value. Per Formik's documentation on forms with checkbox groups, "given that the fields all share the same name, Formik will automagically bind them to a single array". Anytime we click on a checkbox, that checkbox's value (which we've set to the amenity's unique ID) gets added to that array. If we click on the same box again to uncheck it, that value gets removed from the array.

Of course, to accomplish that, let's not forget that Formik's handlers need to get added on to the form elements. The formik.handleChange prop should get passed in to each input's onChange attribute:

// Search.js

// imports

function Search() {

    // State-setting, useEffect fetching, Formik set-up, etc.

    const checkboxes = amenities.map( (amenity) => {
        return (
            <div key={amenity.id}>
                <label>
                    <input
                        type="checkbox"
                        name="checked"
                        value={amenity.id}
                        // Adding Formik's built-in onChange handler.
                        onChange={formik.handleChange}
                    />
                    {amenity.name}
                </label>
                <br />
            </div>
        )
    });

    // Return, etc.
};
Enter fullscreen mode Exit fullscreen mode

The formik.handleSubmit prop should get added to the overall form's onSubmit attribute:

// Search.js

// imports 

function Search() {
    // State-setting, useEffect fetching, Formik set-up, etc.
    // Checkboxes

    return (
        <div>
            <h1>Search</h1>
            <form onSubmit={formik.handleSubmit}>
                {checkboxes}
                <input type="submit" value="Search" />
            </form>
        </div>
    )
};
Enter fullscreen mode Exit fullscreen mode

Now, let's hop back to our useFormik hook and take a closer look at onSubmit. Formik initially receives an array of strings from the form. We're going to want to take those individual strings and join them together into one string, which we can accomplish using the join() method.

Here's what that updated code should look like now:

// Search.js

// imports

function Search() {

    // State-setting, useEffect fetching

    const formik = useFormik({
        initialValues: {
            checked: []
        },
        onSubmit: (values) => {
            const value_array = values['checked'];
            console.log('Value Array:', value_array)
            const value_string = value_array.join();
            console.log('Value String:', value_string)
        }
    })

    // Checkboxes, return, etc.

};
Enter fullscreen mode Exit fullscreen mode

And here's what output should look like when printed in the browser console:

Screenshot of Search page with top three amenities checked, with the browser developer tools open at the side, showing a printout of the checkbox values in the console

Joining these ids together into one string will make communicating our request to the server much easier, as we'll discuss in the next part.

Before I realized that I could pass this string of multiple ids as a parameter in my fetch request to my server, I tried setting up a series of fetch requests with each individual amenity ID to return arrays of matching park objects and then filtering through those arrays to find the intersecting records. I won't go into the nitty-gritty details here, but suffice it to say that, not getting the expected results 30 lines of code later, there is no straightforward way of accomplishing this kind of filtering with JavaScript. However, utilizing the powerful data manipulation capabilities of Python and SQL, this task is much simpler to accomplish on the backend!

Backend

The backend of this project uses Python, Flask, and SQLAlchemy. (Note: I won't get into the details of the initial configuration of those libraries but encourage you to check out the Flask and Flask-SQLAlchemy docs for more background.)

Create API Resource and Route

To build out the route that we'll use to return search results, we'll start by creating a new API resource, 'ParksByAmenityIds', with a GET method that takes a string of ids as a parameter (hey, that sounds like what we just made on the frontend!). Before building out the logic within that resource, let's immediately go ahead and add this resource and the corresponding endpoint below (it is very easy to forget otherwise!).

# app.py

from flask import make_response
from flask_restful import Resource
from config import app, api
from models import Amenity

class ParksByAmenityIds(Resource):
    def get(self, id_string):
        pass

api.add_resource(ParksByAmenityIds, '/park_amenities/<string:id_string>')
Enter fullscreen mode Exit fullscreen mode

With that out of the way, let's build out the underlying logic.

Build Out Query Logic

First, we'll split the id_string into an array of separate ids.

id_array = id_string.split(',')
Enter fullscreen mode Exit fullscreen mode

Next, we'll use a list comprehension to convert those id elements from strings into integers.

int_ids = [int(id) for id in id_array]
Enter fullscreen mode Exit fullscreen mode

Now, we can get a list of the parks that have any (but not all) of the specified amenities by querying the ParkAmenity join table. We can use the .in_ column operator (which operates similarly to Python's in keyword) to return all the parks with a matching amenity id. (For the example I used with amenity ids 1, 2, and 3, there were 276 items returned with duplicates.)

all_matching_amenities = [amenity.to_dict() for amenity in ParkAmenity.query.filter(ParkAmenity.amenity_id.in_(int_ids)).all()]
Enter fullscreen mode Exit fullscreen mode

Next, we'll use another list comprehension to create a new list of all the park IDs from the result of the all_matching_amenities query.

all_park_ids = [element['park']['id'] for element in all_matching_amenities]
Enter fullscreen mode Exit fullscreen mode

Using yet another list comprehension, let's return all of the ids which appear in the park ID list as many times as there are amenities we're searching for (in this case, we've got a list of three amenities). We're using the .count() method as well as finding the length of our amenities list with len() to make this comparison. The result will be a list of all the park ids connected to all the specified amenities.

multiple_matches = [id for id in all_park_ids if all_park_ids.count(id) == len(int_ids)]
Enter fullscreen mode Exit fullscreen mode

We can quickly remove all of the duplicates by turning that list into a set with Python's set() constructor.

unique_matches = set(multiple_matches)
Enter fullscreen mode Exit fullscreen mode

In this example, we've whittled down 276 items that match at least one criterion to a set of 18 items that match all three criteria — fantastic!

Using those unique matches, we can now query the Parks table (again using the .in_ column operator) to return all the parks with an id appearing in our new set of unique matches. This list of park objects (with all their details, not just the ids) is what our server will send back to the frontend in response to our search request.

parks = [park.to_dict() for park in Park.query.filter(Park.id.in_(unique_matches)).all()]
Enter fullscreen mode Exit fullscreen mode

Add Error Handling if No Matches Found

Before we tie this back together with our frontend, we should add some error handling. It will not be uncommon for users to select a combination of criteria that won't yield any matches. We'll want to let the frontend know if this is the case so that it can render an error message to the user accordingly. In this case, we'll add a clause that specifies that if the length of the list of parks returned is 0, the server should send back an error.


if len(parks) == 0:
    response = make_response(
        {"error": "No matching parks."},
        404
        )
    return response

else:
    response = make_response(
        parks,
        200
        )
    return response
Enter fullscreen mode Exit fullscreen mode

Here's what the entire resource looks like, all put together:

# app.py

# imports, additional resources/routes

class ParksByAmenityIds(Resource):
    def get(self, id_string):
        id_array = id_string.split(',')
        int_ids = [int(id) for id in id_array]

        all_matching_amenities = [amenity.to_dict() for amenity in ParkAmenity.query.filter(ParkAmenity.amenity_id.in_(int_ids)).all()]
        all_park_ids = [element['park']['id'] for element in all_matching_amenities]
        multiple_matches = [id for id in all_park_ids if all_park_ids.count(id) == len(int_ids)]
        unique_matches = set(multiple_matches)

        parks = [park.to_dict() for park in Park.query.filter(Park.id.in_(unique_matches)).all()]

        if len(parks) == 0:
            response = make_response(
                {"error": "No matching parks."},
                404
            )
            return response

        else:
            response = make_response(
                parks,
                200
            )
            return response

api.add_resource(ParksByAmenityIds, '/park_amenities/<string:id_string>')
Enter fullscreen mode Exit fullscreen mode

Tying the Frontend and Backend Together

Now that we've got this route built out, let's update the Formik onSubmit handler with that route to fetch the matching parks from the server. We'll add a clause for rendering an error on the search page if no matches are found. We'll set up searchError as a boolean state variable and have the error message render conditionally based on whether the error state is true.

// Search.js

// imports

function Search() {

    const [amenities, setAmenities] = useState([])
    const [searchError, setSearchError] = useState(false)

    // useEffect fetching

    const formik = useFormik({
        initialValues: {
            checked: []
        },
        onSubmit: (values) => {
            const value_array = values['checked'];
            const value_string = value_array.join();
            fetch(`/park_amenities/${value_string}`)
                .then(response => {
                    if (response.ok) {
                        response.json()
                        .then(parkData => console.log(parkData))
                    } else {
                        response.json()
                        .then(error => setSearchError(true))
                    }
                })
        }
    })

    // Checkboxes

    return (
        <div>
            <h1>Search</h1>
            {searchError
                ? <p>No matches found.</p>
                : null
            }
            <form onSubmit={formik.handleSubmit}>
                {checkboxes}
                <input type="submit" value="Search" />
            </form>
        </div>
    )
};
Enter fullscreen mode Exit fullscreen mode

To be able to render those matches in our 'Results' component, we'll need to establish parks as a state variable in the parent component (App) and pass those down to Search and Results as props.

// App.js

// React Imports
import { React, useState } from 'react';

// Additional Imports

function App() {

    const [parks, setParks] = useState([])

    return (
    <Switch>
        <Route path='/search'>
            <Search setParks={setParks}/>
        </Route>
        <Route path='/results'>
            <Results parks={parks}/>
        </Route>
    </Switch>
    )
};

export default App;
Enter fullscreen mode Exit fullscreen mode

Now, upon a successful search, we can use the setParks prop to save those matches in state. We'll also use the useHistory hook from React Router to send us to the Results page upon a successful search using the .push() method.

// Search.js

import { useHistory } from 'react-router-dom';
// Additional Imports

function Search({ setParks }) {
    // state-setting

    const history = useHistory()

    // useEffect fetching

    const formik = useFormik({
        initialValues: {
            checked: []
        },
        onSubmit: (values) => {
            const value_array = values['checked'];
            const value_string = value_array.join();
            fetch(`/park_amenities/${value_string}`)
                .then(response => {
                    if (response.ok) {
                        response.json()
                        .then(parkData => setParks(parkData))
                        .then(() => history.push('/results'))
                    } else {
                        response.json()
                        .then(error => setSearchError(true))
                    }
                })
        }
    })

    // checkboxes, return, etc.
};
Enter fullscreen mode Exit fullscreen mode

Last, let's quickly get a basic Results component built out:

// Results.js

function Results({ parks }) {

    const li_parks = parks.map(park => {
        return <li key={park.id}>{park.name}</li>
    })
    return (
        <div>
            <h1>Results</h1>
            <ul>
                {li_parks}
            </ul>
        </div>
    )
};

export default Results;
Enter fullscreen mode Exit fullscreen mode

And there we have it, a successful search — hopefully the first of many for our users!

Screenshot of the results page, featuring a bulleted list of all 18 parks matching the search criteria

. . . . . . . .