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.
- Remove
src/App.css
(we won’t need this file). - 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
tofalse
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 aprovId
, with enabled set to fetch only ifprovId
is provided.useDistrictQuery
: Fetches districts based on acityId
, with enabled to fetch only ifcityId
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
, anduseDistrictQuery
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.