Google Map Places Autocomplete with Next.js

Bill ZhangBill Zhang
4 min read

Google APIs documents are annoying to read, and they lack support for popular frameworks such as React and Next.js. In this article I’ll show you how I implemented lazy loading the API the simplest way possible.

I have made my own custom react hook for checking whether Google Map API script needs to be loaded or not.

./hooks/use-google-map-api.tsx

'use client';

import * as React from 'react';

interface State {
    needGoogleMapApi: boolean;
    isReady: boolean;
}

const listeners: Array<(state: State) => void> = [];
let memoryState: State = { needGoogleMapApi: false, isReady: false };
let firstTime = true;

function dispatch(updates: Partial<State>) {
    memoryState = { ...memoryState, ...updates };
    listeners.forEach((listener) => {
        listener(memoryState);
    });
}

export function useGoogleMapApi(autoLoad: boolean = false) {
    const [state, setState] = React.useState<State>(() => {
        // Synchronously set needGoogleMapApi if autoLoad is true (genius)
        if (autoLoad && !memoryState.needGoogleMapApi) {
            memoryState.needGoogleMapApi = true;
        }
        return memoryState;
    });

    React.useEffect(() => {
        listeners.push(setState);
        if ((autoLoad && !memoryState.needGoogleMapApi) || firstTime) {
            dispatch({ needGoogleMapApi: true });
            firstTime = false;
        }
        return () => {
            const index = listeners.indexOf(setState);
            if (index > -1) {
                listeners.splice(index, 1);
            }
        };
    }, [autoLoad]);

    const loadGoogleMap = React.useCallback(() => {
        if (!memoryState.needGoogleMapApi) {
            dispatch({ needGoogleMapApi: true });
        }
        return state;
    }, [state]);

    const onReady = React.useCallback(() => {
        dispatch({ isReady: true });
    }, []);

    return {
        ...state,
        loadGoogleMap,
        onReady,
    };
}

The component that is responsible for loading the Google Script tag

./components/google-map-api.tsx

'use client';

import { useGoogleMapApi } from '@/hooks/use-google-map-api';
import Script from 'next/script';

export function GoogleMapApi() {
    const { needGoogleMapApi, onReady } = useGoogleMapApi();

    if (!needGoogleMapApi) return null;

    // Load Google Places API
    return (
        <Script
            defer
            src={`https://maps.googleapis.com/maps/api/js?key=${process.env.NEXT_PUBLIC_GOOGLE_MAPS_API_KEY}&libraries=places`}
            onReady={onReady}
        />
    );
}

You just need to load this file in the layout.tsx and you’re good to go:

import { GoogleMapApi } from '@/components/google-map-api';

export default async function RootLayout({ children }: { children: React.ReactNode }) {
    return (
        <html lang="en">
            <body>
                {children}
                <GoogleMapApi />
            </body>
        </html>
    );
}

Now if you just add the following code to a page that you want to use the API:

const { isReady } = useGoogleMapApi(true);

Or you could lazily load it by doing so:

const { isReady, loadGoogleMap } = useGoogleMapApi(true);
useEffect(() => {
    loadGoogleMap();
}, [])

Here is an example of my app and my UI, we’ll be using the package use-places-autocomplete for easy integration (also assume DasiyUI v4 and Tailwindcss v3 is installed):

npm i use-places-autocomplete

LocationInput.tsx

import { useGoogleMapApi } from '@/hooks/use-google-map-api';
import classNames from 'classnames';
import { useEffect, useId, useRef, useState } from 'react';
import usePlacesAutocomplete from 'use-places-autocomplete';

export function LocationInput({
    defaultValue,
    onChange,
    disabled,
    className,
    ...props
}: {
    defaultValue: string;
    onChange: (value: string) => void;
    disabled?: boolean;
    className?: string;
} & Omit<
    React.InputHTMLAttributes<HTMLInputElement>,
    'onChange' | 'value' | 'defaultValue' | 'disabled' | 'className'
>) {
    const inputRef = useRef<HTMLInputElement>(null);
    const { isReady } = useGoogleMapApi(true);
    const [trackedValue, setTrackedValue] = useState(defaultValue);
    const [selectedIndex, setSelectedIndex] = useState(-1);
    const uniqueId = useId();
    const listId = `location-list-${uniqueId}`;

    const {
        ready,
        value,
        suggestions: { status, data },
        setValue,
        clearSuggestions,
        init,
    } = usePlacesAutocomplete({
        debounce: 100,
        initOnMount: false,
    });

    useEffect(() => {
        if (isReady) init();
    }, [isReady, init]);

    // Custom validation for the input
    useEffect(() => {
        if (trackedValue) inputRef.current?.setCustomValidity('');
        else inputRef.current?.setCustomValidity('You Must Select a Location');
    }, [trackedValue, setValue]);

    useEffect(() => {
        onChange(trackedValue);
    }, [trackedValue, onChange]);

    const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
        if (status !== 'OK') return;

        switch (e.key) {
            case 'ArrowDown':
                e.preventDefault();
                setSelectedIndex((prev) => Math.min(prev + 1, data.length - 1));
                break;
            case 'ArrowUp':
                e.preventDefault();
                setSelectedIndex((prev) => Math.max(prev - 1, -1));
                break;
            case 'Enter':
                e.preventDefault();
                if (selectedIndex >= 0) {
                    const suggestion = data[selectedIndex];
                    setTrackedValue(suggestion.description);
                    setValue(suggestion.structured_formatting.main_text, false);
                    clearSuggestions();
                    setSelectedIndex(-1);
                }
                break;
            case 'Escape':
                clearSuggestions();
                setSelectedIndex(-1);
                break;
        }
    };

    return (
        <div className={classNames('dropdown', className)}>
            <input
                ref={inputRef}
                type="text"
                className="!input !input-bordered w-full invalid:input-error"
                value={!ready ? 'Loading...' : value || trackedValue}
                onChange={(e) => {
                    setValue(e.target.value);
                    setTrackedValue('');
                    setSelectedIndex(-1);
                }}
                onKeyDown={handleKeyDown}
                disabled={!ready || disabled}
                placeholder={'Enter a location'}
                role="combobox"
                aria-expanded={status === 'OK'}
                aria-controls={listId}
                aria-label="Location search"
                aria-autocomplete="list"
                {...props}
            />
            {/* We can use the "status" to decide whether we should display the dropdown or not */}
            {status === 'OK' && (
                <ul
                    id={listId}
                    role="listbox"
                    className="menu dropdown-content z-50 w-full overflow-x-auto rounded-box bg-base-100 p-2 shadow-2xl lg:w-screen lg:max-w-96"
                >
                    {data.map((suggestion, index) => {
                        const {
                            place_id,
                            description,
                            structured_formatting: { main_text, secondary_text },
                        } = suggestion;

                        return (
                            <li
                                key={place_id}
                                role="option"
                                aria-selected={index === selectedIndex}
                                className={classNames(index === selectedIndex && 'bg-base-200')}
                                // Prevent the menu from disappearing when clicking on the suggestion
                                onMouseDown={(e) => e.preventDefault()}
                                onClick={() => {
                                    setTrackedValue(description);
                                    // When the user selects a place, we can replace the keyword without request data from API
                                    // by setting the second parameter to "false"
                                    setValue(main_text, false);
                                    clearSuggestions();
                                    setSelectedIndex(-1);
                                }}
                            >
                                <p className="flex flex-col items-start gap-0">
                                    <strong>{main_text}</strong> <small>{secondary_text}</small>
                                </p>
                            </li>
                        );
                    })}
                </ul>
            )}
        </div>
    );
}
0
Subscribe to my newsletter

Read articles from Bill Zhang directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Bill Zhang
Bill Zhang