Build a Dropdown as a JavaScript Class, Then Port It to React
Requirements
Work in src/App.tsx. KeepVanillaBasemapPicker,StatusandAppas given, and implementpickerReducer, theBasemapPickerclass andReactBasemapPicker.Make pickerReducerpure and use it in both versions: no DOM, no React and no callbacks inside it, and the same state object back when an action changes nothing.Render the same DOM in both versions: a .pickerroot, a.picker-labelspan with the id<idPrefix>-label, abutton.picker-triggerwithrole="combobox",aria-haspopup="listbox",aria-controls,aria-labelledbyandaria-expanded, and aul.picker-listwithrole="listbox"and the id<idPrefix>-listthat stays mounted and ishiddenwhile closed.Render each option as li.picker-optionwithrole="option", the id<idPrefix>-opt-<index>andaria-selected. The trigger text is the selected label.Clicking the trigger opens or closes the list. Clicking an option selects it, closes the list and moves focus back to the trigger. Handle keys on the trigger: ArrowDown and ArrowUp open a closed list on the selected option; in an open list they move the active option and stop at the ends, Home and End jump to the first and last option, Enter selects the active option and Escape closes without selecting. Mark the active option with the class is-activeand point the trigger at it witharia-activedescendantwhile the list is open; remove the attribute when the list closes.Close the list on a click outside the picker by listening on document. Remove that listener indestroy()and in the React effect cleanup.Call onChange(item)only when the selection changes. Choosing the selected option again reports nothing.Render labels as text. The last option is a user-named preset whose name contains angle brackets.
Expected behavior
On first render both triggers show Satellite, both lists are hidden and both status lines report 0 changes. Choosing Streets with the mouse updates that version's trigger and status line to 1 change and puts focus back on its trigger; choosing Streets again keeps the count at 1. ArrowDown opens the list on Satellite, End jumps to the preset, Home returns to Satellite, and two more ArrowDown presses followed by Enter select Streets with focus still on the trigger. Escape, or a click on the panel heading, closes the list and keeps Satellite selected. The preset appears as My preset <draft>in the list, in the trigger and in the status line.A choice in one version never changes the other. After the preview unmounts, no listener remains on document.
Implementation notes
Create the class's elements once in the constructor and update their text and attributes in render(). Rebuilding them on every render loses focus, and the document listener then receives a click target that is no longer in the DOM.Handle option clicks with one listener on the list: find the option with closest('[role="option"]')and read its index from adata-indexattribute.Store the handlers as arrow-function fields, so removeEventListenerreceives the same function thataddEventListenerdid.In React, take the state from useReducer(pickerReducer, …). In event handlers, run the same reducer on the current state to decide whether to callonChange, instead of watching the selection in an effect that also fires on mount.Call preventDefault()only for the keys the picker handles, so the page does not scroll. Enter and Space on a closed trigger already click the button.The preview passes useState,useEffect,useMemo,useRef,useReducer,useContext,createContextanduseCallbackin as globals; reach any other hook throughReact.
Solution idea
Put every state change in one pure pickerReducer over { open, activeIndex, selectedIndex, count }. Both versions send it the same actions, toggle, close, choose and key, and it returns the same object when nothing changes, so each version can skip rendering and side effects.
Public interview reports
These public, self-reported interview accounts informed this practice version. The exercise is modelled on reported rounds, not copied from them, and the reports are not verified official questions or endorsements.
- Taro: Anduril software engineer interview, 2025
A HackerRank coding interview asked for an ES6 class written from a blank document, then the same component set up in Vue, Angular or React; the author kept the exact question private. This item practises that format with a dropdown; it is not the reported task.
- Taro: Anduril software engineer interview, December 2024
An account with a frontend focus lists writing a timer class and then a React app around it. This item practises the same order, a class first and the framework second, with a different component.
Reported in a public Anduril interview account. Anduril frontend interview questions
Solution
Overview
Put every state change in one pure pickerReducer over { open, activeIndex, selectedIndex, count }. Both versions send it the same actions, toggle, close, choose and key, and it returns the same object when nothing changes, so each version can skip rendering and side effects.
The class builds its label, trigger, list and options once and attaches four listeners. Its send(action) method runs the reducer, calls render() and fires onChange only when selectedIndex moved. render() updates text and attributes in place, so the focused trigger survives every update, and destroy() removes all four listeners, including the one on document.
The React component keeps the same contract. useReducer(pickerReducer, …) holds the state, JSX replaces render(), an onClick per option replaces the delegated listener, and an effect with a cleanup replaces the constructor and destroy() pair for the document listener. Event handlers run the same reducer on the current state to decide whether to report a change, which keeps onChange out of the reducer and out of an effect.
**Test cases**
- Both versions render closed with Satellite selected and toggle on trigger clicks.
- A mouse choice updates the trigger, reports one change, keeps the count when the same option is chosen again and returns focus to the trigger.
- Arrow keys, Home and End move the active option and stop at the ends, and Enter selects it.
- Escape and an outside click close the list without changing the selection.
- The preset label renders as text.
- Unmounting leaves no listener on document.
- A choice in one version does not change the other.
One pure reducer, rendered by a class and by a component
Write the reducer first and check it against the keyboard table: on a closed list, ArrowDown and ArrowUp open on the selected option; on an open list they move and stop at the ends, Home and End jump, Enter selects the active option, and Escape and Tab close. A handlesKey helper names the keys whose default action the picker prevents.
Then the class:
- In the constructor, create the label, the trigger button, the list and one
liper option, set the attributes that never change, write labels withtextContentand append everything to the host. - Add four listeners: click and keydown on the trigger, one delegated click on the list, and click on
documentfor outside clicks. send(action)keeps the previous state, runs the reducer, returns early when the object did not change, renders, and callsonChangewhenselectedIndexmoved.render()writes the trigger text,aria-expanded,aria-activedescendant, the list'shiddenflag and each option'saria-selectedandis-activeclass onto the existing nodes.destroy()removes the four listeners and the root.
Then the port. useReducer replaces the state field, JSX replaces render(), an onClick per option replaces the delegated listener, and useRef provides the root for the outside-click test and the trigger for focus. One useEffect adds the document listener and returns its removal; because dispatch is stable, that listener never reads stale state. send runs the reducer on the current state to see whether the selection changes, then dispatches and calls onChange from the event handler.
Files
{
"name": "react-vanilla-dropdown-port",
"version": "0.1.0",
"private": true,
"dependencies": {
"react": "18.x",
"react-dom": "18.x",
"react-scripts": "5.0.1"
},
"scripts": { "start": "react-scripts start" }
}
{
"compilerOptions": {
"jsx": "react-jsx",
"target": "ES2020",
"module": "ESNext",
"strict": true
}
}
<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>React SDK • Basemap Picker</title></head><body><div id="root"></div></body></html>:root {
color-scheme: dark;
}
html, body {
margin: 0;
min-height: 100%;
background: #020617;
font-family: system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif;
}
.main {
min-height: 100vh;
box-sizing: border-box;
background: #020617;
color: #e5e7eb;
display: grid;
align-content: start;
justify-items: center;
gap: 20px;
padding: 24px 16px;
}
.header {
width: 100%;
max-width: 760px;
display: grid;
gap: 4px;
}
.title {
margin: 0;
font-size: 20px;
font-weight: 700;
}
.subtitle {
margin: 0;
font-size: 13px;
line-height: 1.5;
color: #9ca3af;
}
.panels {
width: 100%;
max-width: 760px;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 16px;
}
.panel {
border: 1px solid #1f2937;
border-radius: 16px;
padding: 16px;
display: grid;
align-content: start;
gap: 12px;
}
.panel-title {
margin: 0;
font-size: 15px;
font-weight: 600;
}
.picker {
position: relative;
display: grid;
gap: 6px;
}
.picker-label {
font-size: 13px;
color: #9ca3af;
}
.picker-trigger {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
width: 100%;
border-radius: 10px;
border: 1px solid #374151;
background: #0b1220;
color: #e5e7eb;
padding: 9px 12px;
font: inherit;
font-size: 14px;
text-align: left;
cursor: pointer;
}
.picker-trigger::after {
content: '';
flex: none;
width: 8px;
height: 8px;
border-right: 2px solid #9ca3af;
border-bottom: 2px solid #9ca3af;
transform: translateY(-2px) rotate(45deg);
}
.picker-trigger[aria-expanded='true']::after {
transform: translateY(2px) rotate(225deg);
}
.picker-trigger:focus-visible {
outline: 2px solid #a3e635;
outline-offset: 2px;
}
.picker-list {
position: absolute;
top: 100%;
left: 0;
right: 0;
z-index: 10;
margin: 6px 0 0;
padding: 4px;
list-style: none;
border-radius: 10px;
border: 1px solid #374151;
background: #0b1220;
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.45);
}
.picker-list[hidden] {
display: none;
}
.picker-option {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
border-radius: 8px;
padding: 8px 10px;
font-size: 14px;
overflow-wrap: anywhere;
cursor: pointer;
}
.picker-option:hover,
.picker-option.is-active {
background: #1f2937;
}
.picker-option.is-active {
box-shadow: inset 0 0 0 1px #a3e635;
}
.picker-option[aria-selected='true'] {
color: #a3e635;
font-weight: 600;
}
.picker-option[aria-selected='true']::after {
content: '✓';
flex: none;
}
.status {
margin: 0;
font-size: 13px;
line-height: 1.5;
color: #9ca3af;
}
.status-value,
.status-count {
color: #e5e7eb;
}
.todo {
margin: 0;
font-size: 13px;
color: #f97316;
}
import React, { useEffect, useReducer, useRef, useState } from 'react';
import './App.css';
type Basemap = {
id: string;
label: string;
};
// The last option is a user-named preset. Its name contains angle brackets on purpose.
const BASEMAPS: Basemap[] = [
{ id: 'satellite', label: 'Satellite' },
{ id: 'terrain', label: 'Terrain' },
{ id: 'streets', label: 'Streets' },
{ id: 'preset', label: 'My preset <draft>' }
];
type PickerState = {
open: boolean;
activeIndex: number;
selectedIndex: number;
count: number;
};
type PickerAction =
| { type: 'toggle' }
| { type: 'close' }
| { type: 'choose'; index: number }
| { type: 'key'; key: string };
type PickerOptions = {
items: Basemap[];
selectedId: string;
idPrefix: string;
label: string;
onChange: (item: Basemap) => void;
};
function createPickerState(count: number, selectedIndex: number): PickerState {
return { open: false, activeIndex: selectedIndex, selectedIndex, count };
}
function indexOfId(items: Basemap[], id: string): number {
return Math.max(0, items.findIndex((item) => item.id === id));
}
// Shared logic: no DOM, no React and no callbacks, so both versions call it unchanged.
// It returns the same object when an action changes nothing.
function pickerReducer(state: PickerState, action: PickerAction): PickerState {
switch (action.type) {
case 'toggle':
return state.open
? { ...state, open: false }
: { ...state, open: true, activeIndex: state.selectedIndex };
case 'close':
return state.open ? { ...state, open: false } : state;
case 'choose':
return { ...state, open: false, activeIndex: action.index, selectedIndex: action.index };
case 'key':
return reduceKey(state, action.key);
}
}
function reduceKey(state: PickerState, key: string): PickerState {
if (!state.open) {
// A closed picker opens on the selected option. Enter and Space reach the button's own click.
return key === 'ArrowDown' || key === 'ArrowUp'
? { ...state, open: true, activeIndex: state.selectedIndex }
: state;
}
const last = state.count - 1;
switch (key) {
case 'ArrowDown':
return moveActive(state, Math.min(state.activeIndex + 1, last));
case 'ArrowUp':
return moveActive(state, Math.max(state.activeIndex - 1, 0));
case 'Home':
return moveActive(state, 0);
case 'End':
return moveActive(state, last);
case 'Enter':
return { ...state, open: false, selectedIndex: state.activeIndex };
case 'Escape':
case 'Tab':
return { ...state, open: false };
default:
return state;
}
}
function moveActive(state: PickerState, index: number): PickerState {
return index === state.activeIndex ? state : { ...state, activeIndex: index };
}
// Keys the picker consumes, so the page does not scroll and the button does not click twice.
function handlesKey(state: PickerState, key: string): boolean {
if (!state.open) return key === 'ArrowDown' || key === 'ArrowUp';
return ['ArrowDown', 'ArrowUp', 'Home', 'End', 'Enter', 'Escape'].includes(key);
}
// Plain JavaScript version: builds its DOM once and updates it from one state object.
class BasemapPicker {
private state: PickerState;
private readonly options: PickerOptions;
private readonly root: HTMLDivElement;
private readonly trigger: HTMLButtonElement;
private readonly list: HTMLUListElement;
private readonly optionElements: HTMLLIElement[];
constructor(host: HTMLElement, options: PickerOptions) {
this.options = options;
this.state = createPickerState(options.items.length, indexOfId(options.items, options.selectedId));
this.root = document.createElement('div');
this.root.className = 'picker';
const label = document.createElement('span');
label.id = `${options.idPrefix}-label`;
label.className = 'picker-label';
label.textContent = options.label;
this.trigger = document.createElement('button');
this.trigger.type = 'button';
this.trigger.className = 'picker-trigger';
this.trigger.setAttribute('role', 'combobox');
this.trigger.setAttribute('aria-haspopup', 'listbox');
this.trigger.setAttribute('aria-controls', `${options.idPrefix}-list`);
this.trigger.setAttribute('aria-labelledby', label.id);
this.list = document.createElement('ul');
this.list.id = `${options.idPrefix}-list`;
this.list.className = 'picker-list';
this.list.setAttribute('role', 'listbox');
this.list.setAttribute('aria-labelledby', label.id);
this.optionElements = options.items.map((item, index) => {
const option = document.createElement('li');
option.id = `${options.idPrefix}-opt-${index}`;
option.className = 'picker-option';
option.setAttribute('role', 'option');
option.dataset.index = String(index);
// textContent, never innerHTML: labels can contain characters such as < and >.
option.textContent = item.label;
return option;
});
this.list.append(...this.optionElements);
this.root.append(label, this.trigger, this.list);
host.append(this.root);
this.trigger.addEventListener('click', this.handleTriggerClick);
this.trigger.addEventListener('keydown', this.handleTriggerKeyDown);
this.list.addEventListener('click', this.handleListClick);
document.addEventListener('click', this.handleDocumentClick);
this.render();
}
destroy(): void {
this.trigger.removeEventListener('click', this.handleTriggerClick);
this.trigger.removeEventListener('keydown', this.handleTriggerKeyDown);
this.list.removeEventListener('click', this.handleListClick);
document.removeEventListener('click', this.handleDocumentClick);
this.root.remove();
}
private handleTriggerClick = (): void => {
this.send({ type: 'toggle' });
};
private handleTriggerKeyDown = (event: KeyboardEvent): void => {
if (handlesKey(this.state, event.key)) event.preventDefault();
this.send({ type: 'key', key: event.key });
};
// One listener for every option: event delegation.
private handleListClick = (event: MouseEvent): void => {
const option = (event.target as Element).closest<HTMLElement>('[role="option"]');
if (!option || !this.list.contains(option)) return;
this.send({ type: 'choose', index: Number(option.dataset.index) });
this.trigger.focus();
};
private handleDocumentClick = (event: MouseEvent): void => {
if (!this.root.contains(event.target as Node)) this.send({ type: 'close' });
};
private send(action: PickerAction): void {
const previous = this.state;
const next = pickerReducer(previous, action);
if (next === previous) return;
this.state = next;
this.render();
if (next.selectedIndex !== previous.selectedIndex) {
this.options.onChange(this.options.items[next.selectedIndex]);
}
}
// Updates the existing nodes in place, so the focused trigger is never replaced.
private render(): void {
const { open, activeIndex, selectedIndex } = this.state;
this.trigger.textContent = this.options.items[selectedIndex].label;
this.trigger.setAttribute('aria-expanded', String(open));
if (open) {
this.trigger.setAttribute('aria-activedescendant', this.optionElements[activeIndex].id);
} else {
this.trigger.removeAttribute('aria-activedescendant');
}
this.list.hidden = !open;
this.optionElements.forEach((option, index) => {
option.setAttribute('aria-selected', String(index === selectedIndex));
option.classList.toggle('is-active', open && index === activeIndex);
});
}
}
// React version: the same reducer and DOM contract, with state, markup and listeners moved into React.
function ReactBasemapPicker({ items, selectedId, idPrefix, label, onChange }: PickerOptions) {
const [state, dispatch] = useReducer(pickerReducer, null, () =>
createPickerState(items.length, indexOfId(items, selectedId))
);
const rootRef = useRef<HTMLDivElement>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
const { open, activeIndex, selectedIndex } = state;
// The class adds this listener in its constructor and removes it in destroy(); here the effect
// and its cleanup do the same. dispatch is stable, so the listener never reads stale state.
useEffect(() => {
function handleDocumentClick(event: MouseEvent) {
if (!rootRef.current?.contains(event.target as Node)) dispatch({ type: 'close' });
}
document.addEventListener('click', handleDocumentClick);
return () => document.removeEventListener('click', handleDocumentClick);
}, []);
// Side effects stay in event handlers: report a change only when the selection really changes.
function send(action: PickerAction) {
const next = pickerReducer(state, action);
if (next === state) return;
dispatch(action);
if (next.selectedIndex !== state.selectedIndex) onChange(items[next.selectedIndex]);
}
function handleKeyDown(event: React.KeyboardEvent<HTMLButtonElement>) {
if (handlesKey(state, event.key)) event.preventDefault();
send({ type: 'key', key: event.key });
}
function choose(index: number) {
send({ type: 'choose', index });
triggerRef.current?.focus();
}
const labelId = `${idPrefix}-label`;
const listId = `${idPrefix}-list`;
const optionId = (index: number) => `${idPrefix}-opt-${index}`;
return (
<div className="picker" ref={rootRef}>
<span className="picker-label" id={labelId}>
{label}
</span>
<button
ref={triggerRef}
type="button"
className="picker-trigger"
role="combobox"
aria-haspopup="listbox"
aria-controls={listId}
aria-labelledby={labelId}
aria-expanded={open}
aria-activedescendant={open ? optionId(activeIndex) : undefined}
onClick={() => send({ type: 'toggle' })}
onKeyDown={handleKeyDown}
>
{items[selectedIndex].label}
</button>
<ul className="picker-list" id={listId} role="listbox" aria-labelledby={labelId} hidden={!open}>
{items.map((item, index) => (
<li
key={item.id}
id={optionId(index)}
className={open && index === activeIndex ? 'picker-option is-active' : 'picker-option'}
role="option"
aria-selected={index === selectedIndex}
onClick={() => choose(index)}
>
{item.label}
</li>
))}
</ul>
</div>
);
}
// Given: mounts the class version into a host element and destroys it on unmount.
function VanillaBasemapPicker(props: PickerOptions) {
const hostRef = useRef<HTMLDivElement>(null);
const onChangeRef = useRef(props.onChange);
onChangeRef.current = props.onChange;
useEffect(() => {
const host = hostRef.current;
if (!host) return;
const picker = new BasemapPicker(host, { ...props, onChange: (item) => onChangeRef.current(item) });
return () => picker.destroy();
// The class owns its DOM after mount, so this wrapper mounts it once.
}, []);
return <div className="picker-host" ref={hostRef} />;
}
type ChangeLog = {
label: string;
changes: number;
};
function useChangeLog(initial: Basemap) {
const [log, setLog] = useState<ChangeLog>({ label: initial.label, changes: 0 });
const record = (item: Basemap) => setLog((current) => ({ label: item.label, changes: current.changes + 1 }));
return [log, record] as const;
}
function Status({ log }: { log: ChangeLog }) {
return (
<p className="status" aria-live="polite">
Selected <strong className="status-value">{log.label}</strong>, changes reported:{' '}
<span className="status-count">{log.changes}</span>
</p>
);
}
export default function App() {
const [vanillaLog, recordVanilla] = useChangeLog(BASEMAPS[0]);
const [reactLog, recordReact] = useChangeLog(BASEMAPS[0]);
return (
<main className="main">
<header className="header">
<h1 className="title">Basemap Picker (Solution)</h1>
<p className="subtitle">One picker written twice, as a plain JavaScript class and as a React component. Both pass the same checks.</p>
</header>
<div className="panels">
<section className="panel" data-version="vanilla" aria-labelledby="vanilla-title">
<h2 className="panel-title" id="vanilla-title">
Plain JavaScript
</h2>
<VanillaBasemapPicker items={BASEMAPS} selectedId="satellite" idPrefix="vanilla-basemap" label="Basemap" onChange={recordVanilla} />
<Status log={vanillaLog} />
</section>
<section className="panel" data-version="react" aria-labelledby="react-title">
<h2 className="panel-title" id="react-title">
React
</h2>
<ReactBasemapPicker items={BASEMAPS} selectedId="satellite" idPrefix="react-basemap" label="Basemap" onChange={recordReact} />
<Status log={reactLog} />
</section>
</div>
</main>
);
}
import React from 'react';
import { createRoot } from 'react-dom/client';
import App from './App';
createRoot(document.getElementById('root')!).render(<App />);
Notes & Pitfalls
- Rebuilding the class markup with `innerHTML` on every render: labels with `<` turn into elements, focus drops and the outside-click listener sees a detached target.
- Adding listeners with inline arrow functions and removing new ones in `destroy()`, so nothing is removed.
- Reporting changes from a `useEffect` that watches the selection, which also fires on mount and reports a change nobody made.
- Rendering the React list only while it is open, so `aria-controls` points at nothing and the markup no longer matches the class.
- Keeping picker state in a module-level variable, so the two pickers on the page change together.
- Copying the transitions into the React component instead of reusing the reducer, so the two versions drift apart.
- A pure reducer shared by an imperative and a declarative renderer.
- Event delegation with `closest()` and data attributes.
- Arrow-function class fields for removable listeners.
- Combobox and listbox ARIA with `aria-activedescendant`.
- Effect cleanup for listeners on document.
Common mistakes on this challenge
Remove the document listener in destroy() and in the effect cleanup
Both unmount checks count every listener still attached to `document` after the preview unmounts, so a missing removal in either version fails both.
- Store the class handlers as arrow-function fields and pass the same functions to `removeEventListener` in `destroy()`.
- In React, add the listener inside `useEffect` and return `() => document.removeEventListener('click', handler)`.
- Keep the given wrapper: it calls `picker.destroy()` when the preview unmounts.
Render the same picker contract from one reducer in both versions
Every check runs once against the class and once against the component, so both must render the same classes, ids and ARIA attributes and take their state from `pickerReducer`.
- Implement `pickerReducer` first: toggle, close, choose, and the ArrowDown, ArrowUp, Home, End, Enter and Escape keys, returning the same object when nothing changes.
- Give options the ids `<idPrefix>-opt-<index>`, keep the list mounted with `hidden` while closed, and set `aria-activedescendant` only while open.
- Write labels with `textContent`, call `onChange` only when `selectedIndex` changes, and focus the trigger after a mouse choice.
Similar questions
Guides
Preparing for interviews? Use Frontend Coding Challenges first, then move into a concrete Study Plan before targeted Company Prep.
Built in public
The code behind FrontendAtlas lives on GitHub. A star helps other frontend engineers find it.