Autocomplete through ReactJS Custom Hook

nazifbara

Nazif Barassounon

Posted on October 23, 2021

Autocomplete through ReactJS Custom Hook

Autocomplete is a software feature that makes some suggestions based on what the user is currently typing. This functionality is widely used, from search engines to online shops.

This article shows how to use ReactJS custom Hook to enable simple autocomplete on a country search input.

UI structure

We have an input that aims to help users search for a country name. As the user types, we want to show some suggestions.

the-ui.png

The following shows the code's structure and styling of the search form:

// src/autocomplete/index.js

import styled from 'styled-components';
import { ReactComponent as SearchIcon } from './search-icon.svg';
import countries from './countries';

const SearchForm = () => {

  const handleSubmit = (e) => {
    e.preventDefault();
  };

  return (
    <div>
      <Form 
        autoComplete="off" // disable the browser built-in autocomplete
        onSubmit={handleSubmit}
       >
        <Container>
          <Input/>
          <SubmitButton>
            <SearchIcon />
          </SubmitButton>
        </Container>
      </Form>
    </div>
  );
};

const Container = styled.div`
  position: relative;
`;

const List = styled.div`
  position: absolute;
  top: 100%;
  left: 0;
  right: 0;
  height: 40vh;
  background-color: #293241;
  border-radius: 0 0 5px 5px;
  overflow-y: scroll;
`;

const Item = styled.div`
  border-left: 3px solid ${({ active }) => (active ? 'blue' : 'initial')};
  background-color: ${({ active }) => (active ? 'gray' : 'initial')};
  padding: 0 15px;
  color: ${({ active }) => (active ? 'white' : '#f2e9e4')};
  cursor: pointer;

  &:hover {
    background-color: gray;
  }
`;

const Input = styled.input`
  background-color: #293241;
  color: #f2e9e4;
  border: none;
  width: 400px;
  padding: 15px 15px;
  border-radius: 5px 0 0 0;

  &:focus {
    outline: none;
  }
`;

const Match = styled.strong`
  color: #c9ada7;
`;

const Form = styled.form`
  width: 100vw;
  height: 100vh;
  background-color: #4a4e69;
  padding-top: 10%;
  display: flex;
  align-items: flex-start;
  justify-content: center;
`;

const SubmitButton = styled.button`
  outline: none;
  background-color: #293241;
  padding: 15px 20px;
  border: none;
  color: #f2e9e4;
  border-radius: 0 5px 0 0;

  &:focus {
    outline: none;
  }
`;

export default SearchForm;
Enter fullscreen mode Exit fullscreen mode

The useAutocomplete Hook

useAutocomplete is the custom Hook that will be responsible for the autocomplete logic. First, we handle the search input value change:

// src/autocomplete/useAutocomplete.js

import { useState } from 'react';

const INITIAL_STATE = {
  value: '',
};

const useAutocomplete = (initialState = INITIAL_STATE) => {
  const [{ value }, setState] = useState(initialState);

  const handleValueChange = (newValue) => {
    setState((s) => ({
      ...s,
      value: newValue,
    }));
  };

  return {
    value,
    handleValueChange,
  };
};

export default useAutocomplete;
Enter fullscreen mode Exit fullscreen mode

Now let's use it in the search input:

// src/autocomplete/index.js

//...
import useAutocomplete from './useAutocomplete';

const SearchForm = () => {
  const { value, handleValueChange } = useAutocomplete();
 //...
  return (
    <div>
          //...
          <Input
            value={value}
            onChange={(e) => handleValueChange(e.target.value)}
          />
         //...
    </div>
  );
};
Enter fullscreen mode Exit fullscreen mode

Secondly, we filter the country's names. We get the list of countries as an argument for the handleValueChange function. The result of the filtering, that is, the suggestions, is stored in the Hook's state:

// src/autocomplete/useAutocomplete.js

// ...
const INITIAL_STATE = {
  // ...
  suggestions: [], // contains autocomplete suggestions
};

const useAutocomplete = (initialState = INITIAL_STATE) => {
  const [{ value }, setState] = useState(initialState);

  const handleValueChange = (newValue, items) => {
    setState((s) => ({
      ...s,
      value: newValue,
      suggestions: items
        .filter((item) => {
          /*
            if the letters typed by the user match the beginning
            of the item we keep it
          */
          const match = item.substr(0, newValue.length);
          return match && match.toLowerCase() === newValue.toLowerCase();
        })
        .slice(0, 13), // limits the suggestion to 13 items
    }));
  };

  return {
    // ...
    suggestions, // exports the suggestions
  };
};
// ...
Enter fullscreen mode Exit fullscreen mode

While the user types, we want to style the matching part differently from the rest of each suggestion. To achieve that, we change the suggestion items data structure as follow:

// src/autocomplete/useAutocomplete.js

// ...
  const handleValueChange = (newValue, items) => {
    setState((s) => ({
      // ...
      suggestions: items
        .filter((item) => {
          // ...
        })
        .map((item) => ({ rest: item.substr(newValue.length), itemValue: item }))
        .slice(0, 13),
        // ...
Enter fullscreen mode Exit fullscreen mode

The caller of handleValueChange might want to provide a custom filtering function. Let's extract the current filtering logic in a separate function and accept a custom filtering function as an option:

// src/autocomplete/useAutocomplete.js

// ...

const defaultFilter = (inputValue, items) =>
  items
    .filter((item) => {
      const match = item.substr(0, inputValue.length);
      return match && match.toLowerCase() === inputValue.toLowerCase();
    })
    .map((item) => ({
      rest: item.substr(inputValue.length),
      itemValue: item,
    }))
    .slice(0, 13);

const useAutocomplete = () => {
  // ...

  const handleValueChange = (newValue, items, { customFilter } = {}) => {
    // uses customFilter if defined
    const filter = customFilter ?? defaultFilter;

    setState((s) => ({
      // ...
      suggestions: filter(newValue, items),
    }));
  };
//...
Enter fullscreen mode Exit fullscreen mode

Now, we can show the suggestions to the user:

// src/autocomplete/index.js

// ...
const SearchForm = () => {
  const { value, handleValueChange, suggestions } = useAutocomplete()
  const hasSuggestions = !(Array.isArray(suggestions) && !suggestions.length);

  // ...
          <Input
            value={value}
            onChange={(e) => handleValueChange(e.target.value, data)}
          />
          <SubmitButton>
            <SearchIcon />
          </SubmitButton>
          {hasSuggestions && (
            <List>
              {suggestions.map(({ rest, itemValue }, i) => (
                <Item key={itemValue}>
                  <Match>{value}</Match>
                  {rest}
                </Item>
              ))}
            </List>
          )}
// ...
Enter fullscreen mode Exit fullscreen mode

matching.png

The next thing we want is to select one of the suggestions when we click on it. Let's create a select function in the useAutocomplete Hook:

// src/autocomplete/useAutocomplete.js

// ...
const useAutocomplete = (initialState = INITIAL_STATE) => {
  // ...
  const select = (value) => setState({ ...INITIAL_STATE, value });

  return {
    // ...
    select,
  };
};
// ...
Enter fullscreen mode Exit fullscreen mode

The following manage the click event on each suggestion item:

// src/autocomplete/index.js

// ...
const SearchForm = () => {
  // imports the select function
  const { ..., select } = useAutocomplete();
  // ...
           {suggestions.map(({ rest, itemValue }, i) => (
             <Item key={itemValue} onClick={() => select(itemValue)}>
// ...
Enter fullscreen mode Exit fullscreen mode

An autocomplete input usually allows us to move through the suggestion items using the up and down arrow keys. Also, we can select an active suggestion by pressing the "enter" key. We'll add all these features to our search input.

As we move between suggestions, we need to keep track of the current active suggestion in the Hook's state:

// src/autocomplete/useAutocomplete.js

const INITIAL_STATE = {
 // ...
  currentFocus: null,
};
Enter fullscreen mode Exit fullscreen mode

Initially, currentFocus is set to null. It means there is no focus at all. As the user starts to type, we want the currentFocus to be -1. In this case, the focus is on the input itself.

Why do we need to consider the text input as a focus-able element? To understand, let's consider a specific case. Say a user starts to type and notices that the suggestion at the very bottom matches what the user is looking for. When the user presses the up arrow key, we want the focus to jump straight to the last suggestion.

Apart from null and -1, currentFocus hold the index of the active suggestion.

current-focus.png

With that in mind, the following update the handleValueChange function:

// src/autocomplete/useAutocomplete.js

// ...
const useAutocomplete = (initialState = INITIAL_STATE) => {
// ...
  const handleValueChange = (newValue, items) => {
    setState((s) => ({
      ...s,
      currentFocus: -1, // the focus is on the search input at the beginning
      value: newValue,
      // ...
Enter fullscreen mode Exit fullscreen mode

Then, we handle three types of keys down events:

  • ArrowUp
  • ArrowDown
  • Enter
// src/autocomplete/useAutocomplete.js

// ...
const useAutocomplete = (initialState = INITIAL_STATE) => {
  const [{ value, suggestions, currentFocus }, setState] = useState(initialState);
  // ...
  const handleKeyDown = ({ key }) => {
    switch (key) {
      case 'ArrowDown':
        setState((s) => ({
          ...s,
          currentFocus:
            s.currentFocus === s.suggestions.length - 1
              ? 0
              : s.currentFocus + 1,
        }));
        break;
      case 'ArrowUp':
        setState((s) => ({
          ...s,
          currentFocus:
            s.currentFocus === 0 || s.currentFocus === -1
              ? s.suggestions.length - 1
              : s.currentFocus - 1,
        }));
        break;
      case 'Enter':
        if (currentFocus !== null && currentFocus !== -1) {
          select(suggestions[currentFocus].itemValue);
        }
        break;
      default:
        break;
    }
  };

  return {
   // ...
   handleKeyDown,
   currentFocus,
  };
};
// ...
Enter fullscreen mode Exit fullscreen mode

For the ArrowDown event we check if the active suggestion is at the very bottom: s.currentFocus === s.result.length - 1. If this is the case, we jump to the very top suggestion of index 0 otherwise we move to the next suggestion (s.currentFocus + 1).

With the ArrowUp event, the process is similar but the other way around. If the current focus is on the first suggestion (s.currentFocus === 0) or the input itself (s.currentFocus === -1) we want to move at the very bottom (s.suggestions.length - 1). If not, we go to the previous suggestion (s.currentFocus - 1).

Next, we handle the Enter event by selecting the current active suggestion's value (
select(suggestions[currentFocus].itemValue)
).

The following attach the key down handler to our search input:

// src/autocomplete/index.js

<Input
  onKeyDown={handleKeyDown}
  // ...
/>
Enter fullscreen mode Exit fullscreen mode

Based on the active prop of the styled component, Item, the active suggestion appears differently:

// src/autocomplete/index.js

//...
{result.map(({ match, rest, value }, i) => (
  <Item
    active={i === currentFocus}
    // ...
  >
// ...
Enter fullscreen mode Exit fullscreen mode

Our simple autocomplete input is now completed.

The complete code can be found in this repo

Conclusion

The useAutocomplete Hook abstracted the autocomplete logic. It took control over the changing input value then created some suggestions. The suggestions have been generated using either a default filtering function or a custom one. Also, useAutocomplete took care of some key-down events.

As a result, we have a clean SearchForm component and gained in code reusability.

Thanks for reading!

πŸ’– πŸ’ͺ πŸ™… 🚩
nazifbara
Nazif Barassounon

Posted on October 23, 2021

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related