React Material Dynamically Dependent Dropdown with NodeJS API & SQLite

Overview

This article is a how-to for getting the data for an Autocomplete field which populates a Dropdown field with an API call. The example uses a vehicle makes (autocomplete) and models (dropdown) combination to select a specific automobile. The API is Node Express and uses a SQLite database to keep it light and simple. There are about 20 makes and the models have years but they will be filtered out with using Distinct in the select statements. When a Make is selected a small spinner will show in the Models dropdown field to indicate the field is being populated with new data.

Download Source from GitHub https://github.com/fullstacksoup

GitHub

Part I – React Client Side App

Prerequisites

You will need to create and install the following libraries for this example. I recommend getting the source from GitHub unless you want to try it step by step.

Create a New React-App

npx create react-app react-app

Material UIVersion 5

NOTE: React-Material Version 5 was was released in October of 2021.

For the documentation click here

npm install @emotion/react @emotion/styled @mui/icons-material @mui/material

Axios

Axios is a promise-based HTTP Client.

npm i axios

Autocomplete and Dynamic Drop Down Field

In some cases the first drop down may have hundreds or thousands of options therefore an autocomplete is an ideal control. The second field will have about 4-50 options to choose from and is more suitable for a dropdown field.

When an option is chosen from the autocomplete then an API call to get the models by MakeId will be triggered. The response then fills the dropdown field with a list of models.

Component – Source Code

import React, { useEffect, useState } from 'react';
import InputLabel from '@mui/material/InputLabel';
import MenuItem from '@mui/material/MenuItem';
import FormControl from '@mui/material/FormControl';
import Select from '@mui/material/Select';
import Container from '@mui/material/Container';
import { useTheme } from '@mui/material/styles';
import OutlinedInput from '@mui/material/OutlinedInput';
import TextField from '@mui/material/TextField';
import Autocomplete from '@mui/material/Autocomplete';
import axios from 'axios';
import CircularProgress from '@mui/material/CircularProgress';
import InputAdornment from '@mui/material/InputAdornment';

const ITEM_HEIGHT = 48;
const ITEM_PADDING_TOP = 8;
const MenuProps = {
  PaperProps: {
    style: {
      maxHeight: ITEM_HEIGHT * 4.5 + ITEM_PADDING_TOP,
      width: 250,
    },
  },
};


function getStyles(name, personName, theme) {
  return {
    fontWeight:
      personName.indexOf(name) === -1
        ? theme.typography.fontWeightRegular
        : theme.typography.fontWeightMedium,
  };
}

export default function CustomizedSelects() {
  const theme = useTheme();
  const [isLoading, setIsLoading] = useState(false);
  const [option, setOption] = useState([]);
  const [make, setMake] = useState('');
  const [model, setModel] = useState('');

  const [makeData, setMakeData] = useState([]);
  const [modelData, setModelData] = useState([{Model: 'Please Select Make'}]);
  const [value, setValue] = useState('');

  useEffect(() => {
    const url = '/api/makes';

    axiosConn.get(url)
    .then(function (innerResponse) {
        setMakeData(innerResponse.data.data);
        console.log(innerResponse);
                    
    }).catch(function (innerResponse) {
          console.log(innerResponse);
    })

  }, []);

  const handleChange = (event) => {
    const {
      target: { value },
    } = event;
    setModel(value);
  };
  
  const escapeRegexCharacters = str => str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
  
  const getSuggestions = val => {
    const escapedValue = escapeRegexCharacters(val.trim());
    
    if (escapedValue === '') {
      return [];
    }
  
    const regex = new RegExp('^' + escapedValue, 'i');
  
    return makeData.filter(makeData => makeData.Make);
  }
  

  const onSelectVehicleMakeOption = (option, newValue) => {    
    setIsLoading(true);
    if(newValue != null) {
      const url = `/api/models/${newValue.MakeId}`
      axios.get(url)
      .then(function (results) {
          setModelData(results.data.data);
          console.log(results);
          setTimeout(() => {
            setIsLoading(false);  
          }, 1000);
          
      }).catch(function (innerResponse) {
          console.log(innerResponse);
      })
      setMake(newValue.Make);    
  
    } else {
      setMake([]);    
    }
  }

  return (
    <Container maxWidth="md" style={{marginTop: '35px'}}>      
      <FormControl sx={{ m: 1 }} variant="standard">
        
        <Autocomplete       
          disableClearable    
          options={makeData}
          onChange={(event, newValue) => onSelectVehicleMakeOption(option, newValue)}
          getOptionLabel={(option) => option.Make}
          defaultValue={getSuggestions(value)[0]} 
          sx={{ width: 300 }}
          renderInput= {(params) => {
            console.log('PARAMS', params);
            return (
              <TextField
                {...params}    
                label="Vehicle Make"            
                variant="outlined"
                
                style={{lineHeight: '28px'}}
                onChange={(e) => setValue(e.target.value)}
                placeholder="Enter Make"
                autoComplete="off"
                size={'small'}
                InputProps={{
                  ...params.InputProps,       

                }}
              />
            );
          }}
        />
      </FormControl>

      <FormControl sx={{ m: 1, width: 300 }} size={'small'}>
        <InputLabel id="demo-multiple-name-label">Vehicle Model</InputLabel>
        <Select
          labelId="demo-multiple-name-label"
          id="demo-multiple-name"          
          value={model}
          onChange={handleChange}
          input={<OutlinedInput label="Vehicle Model" />}
          startAdornment={
            <InputAdornment position="start">
              {isLoading === true?
              <CircularProgress size={15}/>
              :
              ''
              }
            </InputAdornment>
          }

          MenuProps={MenuProps}
        >          
          {modelData.map((name) => (
            <MenuItem
              key={name.Model}
              value={name.Model}              
            >
              {name.Model}
            </MenuItem>
          ))}
        </Select>
      </FormControl>
               
   </Container>
  );
}

Breakdown of React Component

Fill in the Make Autocomplete

Initialize the Make data array with an API call to get the Makes. Also initialize the Models Array with the data from SQLite.

  const [makeData, setMakeData] = useState([]);
  const [modelData, setModelData] = useState([{Model: 'Please Select Make'}]);

  useEffect(() => {
    const url = '/api/makes';

    axios.get(url)
    .then(function (innerResponse) {
        setMakeData(innerResponse.data.data);
        console.log(innerResponse);
                    
    }).catch(function (innerResponse) {
          console.log(innerResponse);
    })

  }, []);

The Autocomplete Field – Makes & Make ID

Filter out the escape characters for the material autocomplete. This example is taken from Material UI’s MUI5 page for autocomplete examples here. The disableClearable option is used to prevent clearing out the field. getSuggestions() is how the Make array is filtered when a user types in the autocomplete field,

const escapeRegexCharacters = str => str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
  
  const getSuggestions = val => {
    const escapedValue = escapeRegexCharacters(val.trim());
    
    if (escapedValue === '') {
      return [];
    }
  
    const regex = new RegExp('^' + escapedValue, 'i');
  
    return makeData.filter(makeData => makeData.Make);
  }
  
...
      <FormControl sx={{ m: 1 }} variant="standard">
        
        <Autocomplete       
          disableClearable    
          options={makeData}
          onChange={(event, newValue) => onSelectVehicleMakeOption(option, newValue)}
          getOptionLabel={(option) => option.Make}
          defaultValue={getSuggestions(value)[0]} 
          sx={{ width: 300 }}
          renderInput= {(params) => {
            console.log('PARAMS', params);
            return (
              <TextField
                {...params}    
                label="Vehicle Make"            
                variant="outlined"
                
                style={{lineHeight: '28px'}}
                onChange={(e) => setValue(e.target.value)}
                placeholder=""
                autoComplete="off"
                size={'small'}
                InputProps={{
                  ...params.InputProps,       

                }}
              />
            );
          }}
        />
      </FormControl>

The dropdown field is populated when an option is selected by the autocomplete field. Using the Make ID the API is called to get the specific data set for Models with the Make ID that is sent via Get call onSelectVehicleMakeOption(),

Highly recommend adding a spinner as an adornment in the Dropdown Field so user know there is data being loaded in the field, The isLoad boolean variable is set to 1000ms = 1 second otherwise it won’t display the spinner in the input adornment.

  const [model, setModel] = useState('');

...
  const handleModelChange = (event) => {
    const {
      target: { value },
    } = event;
    setModel(value);
  };


  const onSelectVehicleMakeOption = (option, newValue) => {    
    const url = `/api/models/${newValue.MakeId}`
    axios.get(url)
    .then(function (results) {
        setModelData(results.data.data);   
        setTimeout(() => {
          setIsLoading(false);  
        }, 1000);                   
    }).catch(function (innerResponse) {
        console.log(innerResponse);
    })
    setMake(newValue.Make);    
  
 }
  ...


      <FormControl sx={{ m: 1, width: 300 }} size={'small'}>
        <InputLabel id="demo-multiple-name-label">Vehicle Model</InputLabel>
        <Select
          labelId="demo-multiple-name-label"
          id="demo-multiple-name"          
          value={model}
          onChange={handleChange}
          input={<OutlinedInput label="Vehicle Model" />}
          MenuProps={MenuProps}
          startAdornment={
            <InputAdornment position="start">
              {isLoading === true?
              <CircularProgress size={15}/>
              :
              ''
              }
            </InputAdornment>
          }
        >          
          {modelData.map((name) => (
            <MenuItem
              key={name.Model}
              value={name.Model}              
            >
              {name.Model}
            </MenuItem>
          ))}
        </Select>
      </FormControl>

Part II – NodeJS API

Prerequisites

If you don’t already have Node.js installed then you can get the download and install information from their website at https://nodejs.org/.

Install Express

Create a folder for your project and go into that directory and run the following commands.

npm init
npm install express

Install Nodemon

Nodemon restarts the node.js application when changes are made.

npm install nodemon

Update the “scripts” section of the “package.jsonfile with the following:

  "scripts": {
    "test": "echo "Error: no test specified" && exit 1",
    "start": "nodemon  app.js"
  },

To start the application use npm start instead of node app.js. This enables hot reloads so you don’t have to restart your API every time it is updated.

Install the SQL Lite Driver.

npm install sqlite3

Handling CORS

Cross Origin Resource Sharing is needed if you want to use the API with an external application.

npm i cors

Code for the API

The SQLite database is already populated with a table labeled VehicleMakeModel_LU. This table has about thousand records and it includes the various years which will not be needed for this example but could be a good challenge for those of you that want to take this example one step further,

The API has two get methods, one for all the Makes and the another for the Models by Make ID.

app.js Source Code


const express = require('express');
const app = express();
const port = 44410;
const cors = require('cors');

var sqlite3 = require("sqlite3"),
    TransactionDatabase = require("sqlite3-transactions").TransactionDatabase;

const DBSOURCE = "vehicledb.sqlite";

var db = new TransactionDatabase(
    new sqlite3.Database(DBSOURCE, (err) => {
    if (err) {
      // Cannot open database
      console.error(err.message)
      throw err
    } 
    else {
             
        
    }
  })
);

module.exports = db

app.use(
    express.urlencoded(),
    cors()
);

app.get('/', (req, res) => res.send('API Root'));


app.get("/api/makes", async (req, res, next) => {
    

    try {              
        var sql = "SELECT DISTINCT MakeId, Make FROM VehicleMakeModel_LU"
        var params = []
        db.all(sql, params, (err, rows) => {
            if (err) {
               res.status(400).json({"error":err.message});
               return;
            }
            res.json({
                "message":"success",
                "data":rows
            })
        });
    } catch (err) {
        console.log(err);
    }  
});

app.get("/api/models/:id", async (req, res, next) => {
    
    var params = []         

    try {              
        var sql = "SELECT DISTINCT MakeId, Make, Model FROM VehicleMakeModel_LU WHERE MakeId = ?"
        var params = [req.params.id]
        db.all(sql, params, (err, rows) => {
            if (err) {
            res.status(400).json({"error":err.message});
            return;
            }
            res.json({
                "message":"success",
                "data":rows
            })
        });
    } catch (err) {
        console.log(err);
    }  

});

app.listen(port, () => console.log(`API listening on port ${port}!`));

Running the Example.

Run npm start for the NodeJS API and the React App from the command line or in a VS Code terminal.

Photo by Alex Andrews from Pexels