Create a custom select component in React from scratch using Hooks

· 905 words · 5 minute read

There are out there great React select components that you can just easily implement in your project. But sometimes you just want something else that you cannot achieve with these.

I often recommend using those packages because you don’t want to deal with certain logic and complications, but there are times that you really need it to do something and you cannot do it with any of the packages available. That was our case as working on Github Compare. We started by using React Select which is a pretty solid component, but soon we found out that we needed more than it provides and also there were very old opened issues with it and nowhere closed to be tackled. So we decided to build ours.

Enough yada-yada, this is the component we are going to build:

// TODO: Add gif

We start with a simple React functional component:

import React, { useState } from "react";

export const Select: React.FC = () => {
    // Hook to keep track of the input value
    const [value, setValue] = useState("");
    // Hook to keep track of the dropdown open state
    const [isOpen, setOpen] = useState(false);

    // This function gets called when the input changes
    // and updates the state value
    const handleInputChange = ({
        target: { value }
    }: React.ChangeEvent<HTMLInputElement>) => {
        setValue(value);
    };

    // This function will later render the results
    // to be shown in the dropdown
    const renderResults = () => <li>Hello world</li>;

    // It renders an input element and the results below
    return (
        <div className="control" id="select">
            <input
                type="text"
                className="input is-rounded"
                placeholder="Search for something"
                value={value}
                onChange={handleInputChange}
                onFocus={() => setOpen(true)}
            />
            <div className={isOpen ? "results is-visible" : "results"}>
                {renderResults()}
            </div>
        </div>
    );
};

To be able to close the dropdown when you click away from the input or the results, we are going to create a custom hook where we need to pass a reference of the whole div to the hook. This is the hook:

function useSelectOpenState(
    ref: React.RefObject<HTMLElement>,
    initialState = false
): [boolean, (e: boolean) => void] {
    // Hook to keep track of the dropdown open state,
    // with the default state set to false
    const [isOpen, setOpen] = useState(initialState);

    useEffect(() => {
        // This function is called whenever there is a `mousedown` 
        // or `touchstart` in our page
        const listener = (event: MouseEvent | TouchEvent) => {
            // It checks if the target of the click is inside of the reference
            // that we passed along to the hook, and if it is will set the
            // dropdown visible
            if (ref.current && ref.current.contains(event.target as Node)) {
                setOpen(true);
                return;
            }

            // Otherwise, it will hide it
            setOpen(false);
        };

        // Here we attach the above function to the `mousedown`
        // and `touchstart` event listeners
        document.addEventListener("mousedown", listener);
        document.addEventListener("touchstart", listener);

        // And here we clean up those listeners!
        return () => {
            document.removeEventListener("mousedown", listener);
            document.removeEventListener("touchstart", listener);
        };
    }, [ref, isOpen]);

    return [isOpen, setOpen];
}

In order for this hook to work, we have to use it in our Select Component:

export const Select: React.FC = () => {
    (...)

    // This will hold the reference of our component
    const selectEl = useRef<HTMLDivElement>(null);
    // Now we use our custom hook to keep track the dropdown's 
    // open state. We also need to pass the reference of our HTML element
    const [isOpen, setOpen] = useSelectOpenState(selectEl, false);

    (...)

    // It renders an input element and the results below.
    // We now connected with the reference of `selectEl`
    return (
        <div className="control" id="select" ref={selectEl}>
    (...)

Now if you click inside the input it renders the results, and if you click anywhere on the page outside our component, it should hide the results.

Now this looks more like a dropdown :tada: So the next step is to render some options, but before that when you have an input doing requests to an API you do want to limit the amount of requests using some sort of debounce. For the debounce I use a slightly transformed variation of a debounce hook from https://usehooks.com/useDebounce/.

function useDebounce(value: string, delay: number) {
    // State and setters for debounced value
    const [debouncedValue, setDebouncedValue] = useState(value);
    useEffect(() => {
        // Skips debounce delay if there is no value
        if (!value) {
            setDebouncedValue(value);
            return;
        }
        // Set debouncedValue to value (passed in) after the specified delay
        const handler = setTimeout(() => {
            setDebouncedValue(value);
        }, delay);
        // Return a cleanup function that will be called every time ...
        // ... useEffect is re-called. useEffect will only be re-called ...
        // ... if value changes (see the inputs array below).
        // This is how we prevent debouncedValue from changing if value is ...
        // ... changed within the delay period. Timeout gets cleared and restarted.
        // To put it in context, if the user is typing within our app's ...
        // ... search box, we don't want the debouncedValue to update until ...
        // ... they've stopped typing for more than 500ms.
        return () => {
            clearTimeout(handler);
        };
    }, [value, delay]); // ... need to be able to change that dynamically. // You could also add the "delay" var to inputs array if you ... // Only re-call effect if value changes
    return debouncedValue;
}

Then we have to implement this hook into our component and with it, and effect so when its value changes we do our request and set our results. For the sake of this example, I will skip the request part and mock the results.