City Search I
Prompt
The goal of this exercise is to create a performant and user-friendly search feature that allows users to easily find and select the desired city.
Requirements
- The search feature will be a user interface component that includes a search input field.
- As the user types into the search input field, the component will dynamically suggest and display a list of relevant options based on the user's input.
- The user will then have the option to select an option from the dropdown menu or continue typing to further refine the search.
- Selecting an option from the dropdown will populate that into the search input and close the dropdown.
- API
https://www.frontprep.com/api/cities?query=newyork
You do not need to worry about handling key events. You can focus on implementing the search functionality and displaying the search results
Example
Playground (Prompt 1)
Let's start with the basic structure. What states do we need?
- searchTerm for input value
- isOpen for dropdown visibility
- results for city list
How would you structure the API call?
function fetchCities(query) { return fetch( `https://www.frontprep.com/api/cities?query=${query}` ) .then((res) => res.json()) .then((result) => result);}Solution (Prompt 1)
Explanation
This is a very commonly asked interview challenge. It has been asked at companies like Amazon, Bookings and Thoughtspot. This question tests your ability to fetch data from an API on user input, handle asynchronous operations cleanly, structure components and style a dropdown. Let's walk through it step by step.
Now before we start coding, it is very important to thoroughly read the prompt and understand the requirements. We need to build a search input that fetches city suggestions from an API as the user types. When the user selects a city from the dropdown, it should populate the input and close the dropdown.
Step 1: Setting Up App.js with Search Input
Let's start by creating a searchTerm state and rendering an input field. We want to see the input on screen first and verify it works before adding any API logic.
App.js
import React from 'react';import './styles.css';export default function App() { const [searchTerm, setSearchTerm] = React.useState(''); console.log(searchTerm); return ( <div className="wrapper"> <input type="text" aria-label="Cities" placeholder="Search for a city" value={searchTerm} onChange={(e) => setSearchTerm(e.target.value)} className="city-search-input" /> </div> );}Open the browser, type something in the input and check the console. You should see the searchTerm updating with each keystroke. If that works, we can move on.
Let's understand how the state is connected to the input, because this is a concept called controlled components and interviewers pay attention to this.
We pass value={searchTerm} to the input, which means React controls what the input displays. The input always shows whatever is in searchTerm state. When the user types, the onChange event fires and we call setSearchTerm(e.target.value) to update the state with the new value. React then re-renders, and the input displays the updated searchTerm. So the communication flows both ways: state controls what the input shows, and the input updates the state when the user types.
This is different from an uncontrolled component where you would not pass value and instead read the input's value using a ref. In this case, we use a controlled component because we need full control over the input value -- we need to programmatically update it when a city is selected from the dropdown, and we need to read its value on every keystroke to trigger API calls. There are cases where uncontrolled components make sense, for example when you just need to read the value on form submission and do not need to react to every keystroke.
We add aria-label="Cities" to the input for accessibility since we do not have a visible label element.
Step 2: Creating the Fetch Function and Custom Hook
Now we need to fetch city data from the API when the user types. Instead of putting the fetch logic directly in App.js, we will create a custom hook called useCitySearch. This keeps our data fetching logic separate from our UI logic which is cleaner and more reusable.
First, let's create a function to fetch cities from the API:
const CITY_SEARCH_ENDPOINT = 'https://www.frontprep.com/api/cities';function fetchCities(value) { return fetch(`${CITY_SEARCH_ENDPOINT}?query=${value}`) .then((r) => r.json()) .then((result) => result);}We store the API endpoint in a constant CITY_SEARCH_ENDPOINT at the top of the file. This is a good practice because if the URL changes, we only need to update it in one place.
Now let's create the custom hook:
hooks/useCitySearch.js
import { useState, useEffect } from 'react';const CITY_SEARCH_ENDPOINT = 'https://www.frontprep.com/api/cities';function fetchCities(value) { return fetch(`${CITY_SEARCH_ENDPOINT}?query=${value}`) .then((r) => r.json()) .then((result) => result);}function useCitySearch(searchTerm) { const [cities, setCities] = useState([]); useEffect(() => { if (searchTerm.trim() !== '') { fetchCities(searchTerm).then((response) => { setCities(response.cities.slice(0, 5)); }); } }, [searchTerm]); return cities;}export { useCitySearch };The hook takes searchTerm as a parameter. Inside useEffect, whenever searchTerm changes, we call fetchCities and update the cities state with the response. We use searchTerm.trim() !== '' to make sure we do not make an API call when the input is empty or contains only spaces.
We also use .slice(0, 5) to limit the results to 5 cities. This keeps the dropdown manageable and is a common pattern in autocomplete implementations.
Now let's update App.js to use this hook and verify the data is coming through:
App.js
import React from 'react';import './styles.css';import { useCitySearch } from './hooks/useCitySearch';export default function App() { const [searchTerm, setSearchTerm] = React.useState(''); const cities = useCitySearch(searchTerm); console.log(cities); return ( <div className="wrapper"> <input type="text" aria-label="Cities" placeholder="Search for a city" value={searchTerm} onChange={(e) => setSearchTerm(e.target.value)} className="city-search-input" /> </div> );}Open the browser, type a city name and check the console. You should see an array of city objects being logged. If you see the data, our API integration is working.
Custom Hook vs Inline Fetch
There is nothing wrong with putting the fetch call and useEffect directly inside App.js. It works perfectly fine. However, if you are comfortable with custom hooks, extracting the fetch logic into a hook like useCitySearch is a nice improvement because it separates concerns: App.js handles UI, the hook handles data. If you are not comfortable with custom hooks, start with the fetch logic inside App.js and if time permits, extract it into a custom hook later.
Interview Tip
Always use console.log to verify the API response before trying to render it. Many candidates skip this step, start rendering immediately, and then spend time debugging why nothing shows up. The issue is often that the data shape is not what they expected. Check the console first, then render.
Step 3: Creating the CityList Component and Rendering Results
Now that we have the data, let's create a CityList component to render the search results and display them below the input.
components/CityList.js
import React from 'react';export default function CityList({ searchResults, onCitySelect,}) { return ( <ul className="list"> {searchResults?.map((result) => ( <li className="city-list-item" key={result.id} onClick={() => onCitySelect(result.name)} > {result.name} </li> ))} </ul> );}We use a <ul> with <li> elements for semantic HTML. Each list item has an onClick handler that calls onCitySelect with the city name. We use optional chaining searchResults?.map as a safety measure in case the results are undefined.
Now let's update App.js to render the dropdown with the results. We also need an isListOpen state to control when the dropdown is visible, and a handler for when a city is selected:
App.js
import React from 'react';import './styles.css';import CityList from './components/CityList';import { useCitySearch } from './hooks/useCitySearch';export default function App() { const [isListOpen, setIsListOpen] = React.useState(false); const [searchTerm, setSearchTerm] = React.useState(''); const cities = useCitySearch(searchTerm); const handleCitySelect = (value) => { setSearchTerm(value); setIsListOpen(false); }; return ( <div className="wrapper"> <input type="text" aria-label="Cities" placeholder="Search for a city" value={searchTerm} onChange={(e) => setSearchTerm(e.target.value)} className="city-search-input" onFocus={() => setIsListOpen(true)} /> {isListOpen && searchTerm && ( <div className="list-wrapper"> <CityList onCitySelect={handleCitySelect} searchResults={cities} /> </div> )} </div> );}Let's walk through the important decisions here.
We show the dropdown only when both isListOpen is true AND searchTerm is not empty. This prevents showing an empty dropdown when the user focuses the input without typing anything.
When the user selects a city, handleCitySelect does two things: it sets the searchTerm to the selected city name (populating the input) and sets isListOpen to false (closing the dropdown). This matches the requirements exactly.
We use onFocus on the input to reopen the dropdown when the user clicks back into the input after selecting a city. Without this, the user would have to clear the input and retype to see suggestions again.
Check the browser. Type a city name, you should see the dropdown with results. Click on a city, the input should be populated and the dropdown should close.
Dropdown Visibility
A common mistake is not handling the dropdown visibility correctly. Some candidates always show the dropdown whenever there are results, even after the user has selected a city. Others forget to reopen the dropdown when the user focuses back on the input. The combination of isListOpen state with onFocus to reopen and closing on selection is the clean approach.
Step 4: Handling Race Conditions
There is an important bug in our current solution. When the user types fast, multiple API calls are made in quick succession. The responses might come back in a different order than the requests were sent. For example, if the user types "lo" and then "los", the response for "los" might arrive before "lo". When "lo" eventually resolves, it would overwrite the "los" results with stale data.
To fix this, we use a cleanup function in our useEffect with an isFresh flag:
hooks/useCitySearch.js (updated)
import { useState, useEffect } from 'react';const CITY_SEARCH_ENDPOINT = 'https://www.frontprep.com/api/cities';function fetchCities(value) { return fetch(`${CITY_SEARCH_ENDPOINT}?query=${value}`) .then((r) => r.json()) .then((result) => result);}function useCitySearch(searchTerm) { const [cities, setCities] = useState([]); useEffect(() => { if (searchTerm.trim() !== '') { let isFresh = true; fetchCities(searchTerm) .then((response) => { if (isFresh) setCities(response.cities.slice(0, 5)); }) .catch(() => { if (isFresh) setCities([]); }); return () => (isFresh = false); } }, [searchTerm]); return cities;}export { useCitySearch };There are a few important things happening here.
First, we handle race conditions using an isFresh flag. We create a local variable isFresh set to true. When the fetch resolves, we only update state if isFresh is still true. When searchTerm changes and the effect re-runs, React first calls the cleanup function from the previous render, which sets the previous isFresh to false. This means any pending response from the previous search term will be ignored because its isFresh is now false. Some candidates use AbortController to cancel the actual HTTP request, which is also valid. The isFresh approach is simpler and achieves the same goal of preventing stale data from updating the UI.
Second, we add a .catch() to handle API failures gracefully. If the fetch fails, we reset cities to an empty array so the dropdown does not show stale results from a previous successful request. Without this, an unhandled promise rejection would show up in the console.
Race Conditions in Data Fetching
This is one of the most commonly missed issues in interview challenges that involve fetching data on user input. If you do not handle race conditions, the interviewer will likely ask you about it. Even if your solution works visually because the network is fast, the interviewer knows the bug is there. Always bring up race conditions proactively and show that you know how to handle them. It shows the interviewer that you think about edge cases and build production-quality code.
Step 5: Styling
Let's make sure our city search looks close to the example.
*,*::before,*::after { box-sizing: border-box; padding: 0; margin: 0;}html,body { width: 100%;}ul { list-style-type: none;}.city-search-input { height: 40px; width: 100%; border: 1px solid rgba(0, 0, 0, 0.4); font-size: 16px; padding: 12px; font-weight: 300; border-radius: 4px;}.wrapper { width: 280px; margin: 0 auto; padding-top: 20px; position: relative;}.list-wrapper { position: absolute; width: 100%; border: 1px solid rgba(0, 0, 0, 0.4); border-radius: 4px; margin-top: 12px;}.city-list-item { padding: 12px; cursor: pointer;}.city-list-item:hover { background: rgba(0, 0, 0, 0.1);}The most important CSS decision here is using position: relative on the .wrapper and position: absolute on the .list-wrapper. This is how we make the dropdown float below the input without pushing other content down. The dropdown is positioned relative to the wrapper, not the page. This is the standard pattern for building dropdowns and autocomplete menus.
We also add cursor: pointer and a hover effect on list items to give visual feedback that they are clickable. We use list-style-type: none on <ul> to remove the default bullet points.
Communicate While You Code
Throughout the interview, talk about the important decisions you are making. For example:
- I am creating a custom hook for the search logic because it separates data fetching from UI
- I am using a cleanup function in useEffect to handle race conditions
- I am using position absolute for the dropdown because it should float without affecting the layout
- I am trimming the search term before making the API call to avoid unnecessary requests
The interviewer loves candidates who can reason about their code out loud.
Interviewer Criteria
HTML/CSS
Employ semantic HTML for accessibility and SEO.
Avoid unnecessary CSS nesting and specificity.
Use descriptive and consistent CSS class names.
Ensure the layout aligns with the provided image.
Style the UI efficiently, within 10 minutes.
JavaScript
Proficiency with functional components.
Capable of fetching and utilizing API data.
Efficiently use ES6 features like
let,const, arrow functions, promises, and destructuring.Cleanly handle conditionals
Trim input before API calls to prevent unnecessary requests.
Cache API results for repeated queries.
Swiftly implement debouncing logic.
React
Comfortable using React hooks.
Properly colocate state.
Effectively use the
keyprop.Create a custom
useDebouncehook.Implement clean data-fetching logic on keystrokes.
Component Architecture
Create separate
SearchInputandSearchResultscomponents.Name classes, functions, handlers, and components clearly and understandably.
Organize components and hooks into separate folders.
Performance
Cache API results for repeated queries.
Efficiently implement debouncing logic.
Time Checkpoints
- 10:00 AM
Interview starts 👥
- 10:03 AM
Interviewer introduces the prompt
- 10:05 AM
Candidate reviews the prompt, seeks clarification as needed, and initiates coding
- 10:10 AM
Search input rendered and bound to the query state
- 10:20 AM
Data fetched from the API and stored in the cities state
- 10:25 AM
List of results rendered using API data stored in cities state
- 10:35 AM
Styling completed for typeahead
- 10:40 AM
Efficient caching mechanisms implemented
- 10:50 AM
Debouncing implemented using useDebounce custom hook
- 10:55 AM
Discussion on advanced questions with the interviewer
- 11:00 AM
Interview ends ✅