Create Select-chaining Feature In React

Updated at

Guide on how to create fully functional select-chaining application using React.

When using shipping online applications, you may encounter a common feature where your current selection depends on the previous selection. For instance, you may need to choose a district, but that choice depends on the state you have selected. In this post, we will creating a React component that incorporates this dependent selection functionality. This component will enable users to select a city only after filling in the corresponding province. To get started, please ensure that you have Node.js installed on your machine, along with either NPM, Yarn, or PNPM.

Installation

To get started, create a new Vite application with React and TypeScript using the following commands:

pnpm create vite react-select-chaining --template react-ts
cd react-select-chaining
pnpm install

Adding Tailwind CSS

Next, install Tailwind CSS and its dependencies:

pnpm add -D tailwindcss postcss autoprefixer
pnpm dlx tailwindcss init -p

Then, configure Tailwind to scan your files by updating the tailwind.config.js file. Adjust the content array as shown below:

/** @type {import('tailwindcss').Config} */
export default {
  content: [
    "./index.html",
    "./src/**/*.{ts,tsx}",
  ],
  theme: {
    extend: {},
  },
  plugins: [],
}

Finally, include Tailwind’s base, components, and utilities by adding the following directives to your main CSS file at src/main.css:

@tailwind base;
@tailwind components;
@tailwind utilities;

@layer base {
	html,
	body,
	#root {
		height: 100%;
	}
}

Setting Up the Skeleton Component

Now that your environment is ready, let’s set up the initial structure of your application.

  1. Remove src/App.css (we won’t need this file).
  2. Open src/App.tsx in your editor and add the following code to define the layout and styles:
import { useState } from "react"

function App() {
  const [locations, setLocations] = useState({
    province: '',
    city: '',
    district: '',
  })

  return (
    <div className="grid h-full place-content-center bg-blue-100">
      <div className="mx-auto w-[500px] rounded-lg bg-white p-4 shadow-md">
        <h1 className="text-xl font-semibold">React Select Chaining</h1>
      </div>
    </div>
  )
}

export default App

Please ensure that the component structure is functioning correctly by executing the following command in your terminal or command prompt.

pnpm run dev

After running the command, open your web browser and navigate to http://localhost:5173/.

Creating the Fetch Function

Next, we’ll set up the fetch function using ofetch and Tanstack Query. Since we’re building a select-chaining feature to handle nested location selection, I’ll use a static API for this tutorial. You can use alternatives like countrystatecity or restcountries, but ensure they provide strong security options.

I’m using ofetch because it’s simpler and offers more features than the native fetch API. However, feel free to use the standard fetch API if you prefer.

First, install the required dependencies:

pnpm add @tanstack/react-query @tanstack/react-query-devtools ofetch

Open the src/main.tsx file and add the following code to set up TanStack Query:

import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'

import App from './App.tsx'
import './index.css'

const queryClient = new QueryClient({
	defaultOptions: {
		queries: {
			refetchOnWindowFocus: false,
		},
	},
})

createRoot(document.getElementById('root')!).render(
	<StrictMode>
		<QueryClientProvider client={queryClient}>
			<ReactQueryDevtools initialIsOpen={false} />
			<App />
		</QueryClientProvider>
	</StrictMode>,
)

Note: Setting refetchOnWindowFocus to false is ideal for static APIs that don’t change frequently.

Understand the API Responses

Here are sample API responses for provinces, cities, and districts:

// Province
{
  "id": "36",
  "name": "BANTEN"
}

// City
{
  "id": "3101",
  "province_id": "31",
  "name": "KABUPATEN KEPULAUAN SERIBU"
}

// District
{
  "id": "3171020",
  "regency_id": "3171",
  "name": "PASAR MINGGU"
},

Each response provides location details with a unique id and name. Cities and districts include references to their associated provinces and regencies through province_id and regency_id, respectively, making it easy to integrate with our components.

Define TypeScript Types

Now, let’s create a file src/locations/types.ts to define TypeScript types for these locations:

export interface BaseLocation {
	id: string
	name: string
}

export type ProvinceProps = BaseLocation

export type CityProps = BaseLocation & {
	province_id: string
}

export type DistrictProps = BaseLocation & {
	regency_id: string
}

Create a Custom Hook for Location Data

Next, let’s create a new file src/locations/use-location.ts to define a custom hook for fetching location data.

import { useQuery, UseQueryResult } from '@tanstack/react-query'
import { ofetch } from 'ofetch'
import { CityProps, DistrictProps, ProvinceProps } from './types'

const BASE_URL = 'https://www.emsifa.com/api-wilayah-indonesia/api'

// Generic fetcher function to handle API requests
async function fetcher<TResult>(url: string): Promise<TResult> {
	try {
		return await ofetch<TResult>(url)
	} catch (error) {
		console.error('Fetch error:', error)
		throw error
	}
}

// Hook to fetch provinces
export function useProvinceQuery(): UseQueryResult<ProvinceProps[], Error> {
	return useQuery({
		queryKey: ['province'],
		queryFn: () => fetcher<ProvinceProps[]>(`${BASE_URL}/provinces.json`),
	})
}

// Hook to fetch cities within a province
export function useCityQuery(
	provId: string | null,
): UseQueryResult<CityProps[], Error> {
	return useQuery({
		queryKey: ['city', provId],
		queryFn: () => fetcher<CityProps[]>(`${BASE_URL}/regencies/${provId}.json`),
		enabled: !!provId, // Only fetch if provId is provided
	})
}

// Hook to fetch districts within a city
export function useDistrictQuery(
	cityId: string | null,
): UseQueryResult<DistrictProps[], Error> {
	return useQuery({
		queryKey: ['district', cityId],
		queryFn: () =>
			fetcher<DistrictProps[]>(`${BASE_URL}/districts/${cityId}.json`),
		enabled: !!cityId, // Only fetch if cityId is provided
	})
}

Explanation:

Fetcher Function: A generic fetcher function is created to simplify API calls and handle potential errors.

Hooks:

  • useProvinceQuery: Fetches all provinces.
  • useCityQuery: Fetches cities based on a provId, with enabled set to fetch only if provId is provided.
  • useDistrictQuery: Fetches districts based on a cityId, with enabled to fetch only if cityId is provided.

Each hook leverages Tanstack Query to efficiently manage data fetching and caching, making the data easily accessible in your components.

Integrate the Component with API Data

With the component structure and API functionality set up, let’s bring them together by creating a reusable Select component. This component will handle loading states, dynamic options, and conditional styling.

First, install the clsx package to easily manage conditional class names:

pnpm add clsx

Next, create a new file at src/locations/select.tsx and add the following code:

import clsx from 'clsx'
import { BaseLocation } from './types'

type SelectProps = {
	label: string
	name: string
	options: BaseLocation[] | undefined
	value: string
	onChange: (value: string) => void
	disabled?: boolean
	loading?: boolean
}

export function Select({
	label,
	name,
	options,
	value,
	onChange,
	disabled = false,
	loading = false,
}: SelectProps) {
	return (
		<legend className="flex flex-col gap-1">
			<label htmlFor={name}>{label}</label>
			<select
				name={name}
				id={name}
        className={clsx(
          'border rounded-md px-3 py-2 focus:outline-none focus:ring-2 transition duration-150 ease-in-out',
          {
            'bg-blue-100 border-blue-300 text-blue-800 cursor-not-allowed': disabled || loading,
            'bg-white border-blue-500 text-blue-900 hover:border-blue-600 focus:border-blue-700 focus:ring-blue-200': !disabled && !loading
          }
        )}
				value={value}
				onChange={(e) => onChange(e.target.value)}
				disabled={disabled || loading}
			>
				<option value="">Select {label}</option>
				{loading ? (
					<option disabled>Loading...</option>
				) : (
					options?.map((option) => (
						<option key={option.id} value={option.id}>
							{option.name}
						</option>
					))
				)}
			</select>
		</legend>
	)
}

This component uses clsx to conditionally apply styles based on disabled and loading states, making it easy to add or remove classes based on component state. It provides a basic dropdown, with a loading message displayed when data is still being fetched.

Final Step: Integrate in the App Component

Now that all components and hooks are in place, let’s bring everything together in the main app file.

Navigate to src/App.tsx and update it with the following code:

import { useState } from 'react'
import {
	useCityQuery,
	useDistrictQuery,
	useProvinceQuery,
} from './locations/use-location'
import { Select } from './locations/select'

function App() {
	const [locations, setLocations] = useState({
		province: '',
		city: '',
		district: '',
	})

	const {
		data: provinceData,
		isLoading: provinceLoading,
		error: provinceError,
	} = useProvinceQuery()

	const {
		data: cityData,
		isLoading: cityLoading,
		error: cityError,
	} = useCityQuery(locations.province)

	const {
		data: districtData,
		isLoading: districtLoading,
		error: districtError,
	} = useDistrictQuery(locations.city)

	const handleLocationChange =
		(key: keyof typeof locations) => (value: string) => {
			setLocations((prev) => ({ ...prev, [key]: value }))
			if (key === 'province')
				setLocations((prev) => ({ ...prev, city: '', district: '' }))
			if (key === 'city') setLocations((prev) => ({ ...prev, district: '' }))
		}

	if (provinceLoading) return <div>Loading...</div>
	if (provinceError || cityError || districtError)
		return <div>Error occurred</div>

	return (
		<div className="grid h-full place-content-center bg-blue-100">
			<div className="mx-auto w-[500px] rounded-lg bg-white p-4 shadow-md">
				<h1 className="text-xl font-semibold">React Select Chaining</h1>
				<form className="mt-4 flex flex-col gap-2">
					<Select
						label="Province"
						name="province"
						options={provinceData}
						value={locations.province}
						onChange={handleLocationChange('province')}
						loading={provinceLoading}
					/>

					<Select
						label="City"
						name="city"
						options={cityData}
						value={locations.city}
						onChange={handleLocationChange('city')}
						disabled={!locations.province}
						loading={cityLoading}
					/>

					<Select
						label="District"
						name="district"
						options={districtData}
						value={locations.district}
						onChange={handleLocationChange('district')}
						disabled={!locations.city}
						loading={districtLoading}
					/>
				</form>
			</div>
		</div>
	)
}

export default App

Explanation:

  • State Management: locations state keeps track of the selected province, city, and district.
  • API Data Fetching: We call useProvinceQuery, useCityQuery, and useDistrictQuery to fetch data for each level of location.
  • Conditional Rendering: Shows loading and error messages based on the query states.
  • Dynamic Select Component: Each Select component dynamically enables or disables based on the selected parent location.

This setup effectively uses chained selects for location data, creating a user-friendly way to navigate through provinces, cities, and districts.

Wrapping Up

Thank you for following along, from building the select-chaining feature. I hope this guide has made the process smoother. If you get stuck or notice anything that could be improved, feel free to check out the complete code on my GitHub Repository.

Share this to: