javascriptroom blog

React Custom Hook: Understanding useDropdown Usage – Clearing Common Confusions

Dropdowns (or select inputs) are ubiquitous in web applications—from form fields to navigation menus, they help users make selections efficiently. However, managing dropdown state, handling user interactions, and reusing dropdown logic across components can quickly become repetitive and error-prone. This is where custom React hooks shine, and useDropdown is a perfect example.

In this blog, we’ll demystify the useDropdown custom hook: what it is, how it works, and how to use it effectively. We’ll tackle common confusions developers face (like handling initial values, dynamic options, or controlled vs. uncontrolled behavior) with practical examples and clear explanations. By the end, you’ll be able to build reusable, robust dropdowns with confidence.

2025-11

Table of Contents#

  1. What is useDropdown?
  2. Anatomy of a Basic useDropdown Hook
  3. Common Confusions & How to Resolve Them
  4. Practical Example: Building useDropdown from Scratch
  5. Advanced Use Cases
  6. Best Practices
  7. Conclusion
  8. References

What is useDropdown?#

useDropdown is a custom React hook designed to encapsulate the logic for managing dropdown (select input) state and behavior. Its core purpose is to reuse dropdown logic across components, reducing boilerplate and ensuring consistency.

Instead of rewriting state management, change handlers, and validation for every dropdown in your app, useDropdown abstracts these concerns into a single, reusable function. This makes your code cleaner, easier to test, and simpler to maintain.

Anatomy of a Basic useDropdown Hook#

At its core, useDropdown manages two key pieces of state:

  • The selected value (what the user has chosen).
  • The options (the list of choices available to the user).

It also provides a change handler to update the selected value when the user interacts with the dropdown.

A minimal useDropdown hook might look like this:

import { useState } from 'react';
 
function useDropdown(initialValue, options) {
  // State to track the selected value
  const [selectedValue, setSelectedValue] = useState(initialValue);
 
  // Handler to update state when the user selects an option
  const handleChange = (e) => {
    setSelectedValue(e.target.value);
  };
 
  // Return the selected value, change handler, and options for reuse
  return { selectedValue, handleChange, options };
}

This basic version works for simple cases, but real-world dropdowns often require more: validation, dynamic options, reset functionality, etc. Let’s address common pain points next.

Common Confusions & How to Resolve Them#

Even with a basic hook, developers often stumble over edge cases. Let’s break down these confusions and fix them.

Confusion 1: Handling Initial Values#

The Problem: What if the initialValue isn’t a valid option (e.g., it’s undefined, or doesn’t exist in options)? The dropdown may render an empty or invalid selection.

The Solution: Validate the initial value against the options list. If it’s invalid, default to null or the first option.

Enhanced Hook Code:

function useDropdown(initialValue, options) {
  // Validate initialValue exists in options
  const isValidInitialValue = options.some(option => option.value === initialValue);
  const safeInitialValue = isValidInitialValue ? initialValue : null;
 
  const [selectedValue, setSelectedValue] = useState(safeInitialValue);
 
  const handleChange = (e) => {
    setSelectedValue(e.target.value);
  };
 
  return { selectedValue, handleChange, options };
}

Why It Works: We check if initialValue is present in options before initializing state. If not, we default to null (which renders as an empty selection in a <select>).

Confusion 2: Inconsistent Options Structure#

The Problem: Options are often passed as arrays of strings (e.g., ['Apple', 'Banana']) or objects with random keys (e.g., { name: 'Apple', id: 1 }). This inconsistency breaks reusability.

The Solution: Enforce a standard structure for options (e.g., { label: string, value: string }). This ensures the hook (and components using it) always knows how to render labels and track values.

Enhanced Hook Code:

// Define a type for options (TypeScript) or add runtime checks (JavaScript)
function useDropdown(initialValue, options) {
  // Validate options structure (throw error if invalid)
  options.forEach((option, index) => {
    if (!option.label || !option.value) {
      throw new Error(`Option at index ${index} is invalid: must have "label" and "value".`);
    }
  });
 
  // ... (rest of the hook logic)
}

Usage Example:

// Valid options (consistent structure)
const fruitOptions = [
  { label: 'Apple', value: 'apple' },
  { label: 'Banana', value: 'banana' },
];
 
// Use the hook with valid options
const { selectedValue, handleChange } = useDropdown('apple', fruitOptions);

Why It Works: By enforcing label and value keys, the hook (and components) can reliably render option.label in the UI and track option.value as the selected value.

Confusion 3: Dynamic Options & Stale State#

The Problem: If options change dynamically (e.g., fetched from an API), the selected value might become invalid (e.g., the selected option is removed from options). The hook won’t detect this, leading to stale state.

The Solution: Add a useEffect to re-validate selectedValue whenever options change.

Enhanced Hook Code:

import { useState, useEffect } from 'react';
 
function useDropdown(initialValue, options) {
  // ... (previous validation for initialValue and options)
 
  const [selectedValue, setSelectedValue] = useState(safeInitialValue);
 
  // Re-validate selectedValue when options change
  useEffect(() => {
    const isValid = options.some(option => option.value === selectedValue);
    if (!isValid) setSelectedValue(null); // Reset to null if invalid
  }, [options, selectedValue]); // Re-run when options or selectedValue changes
 
  const handleChange = (e) => {
    setSelectedValue(e.target.value);
  };
 
  return { selectedValue, handleChange, options };
}

Why It Works: The useEffect checks if selectedValue is still valid whenever options update. If not (e.g., the selected option was removed), it resets to null.

Confusion 4: Controlled vs. Uncontrolled Dropdowns#

The Problem: Should the dropdown be controlled by the hook’s internal state, or by an external prop (e.g., from a parent component or form library)?

The Solution: Support both! Use a "controlled" mode if an external value prop is provided, or fall back to internal state. This makes the hook flexible for forms and standalone use.

Enhanced Hook Code:

function useDropdown(initialValue, options, { value: externalValue, onChange: externalOnChange } = {}) {
  // Use externalValue if provided (controlled mode), else internal state
  const [internalValue, setInternalValue] = useState(safeInitialValue);
  const selectedValue = externalValue !== undefined ? externalValue : internalValue;
 
  const handleChange = (e) => {
    const newValue = e.target.value;
    // Update internal state if uncontrolled
    if (externalValue === undefined) {
      setInternalValue(newValue);
    }
    // Notify parent if controlled
    if (externalOnChange) {
      externalOnChange(newValue);
    }
  };
 
  return { selectedValue, handleChange, options };
}

Usage Example (Controlled):

// Parent component controls the value
function Form() {
  const [selectedFruit, setSelectedFruit] = useState(null);
  return (
    <Dropdown
      options={fruitOptions}
      value={selectedFruit} // Controlled by parent state
      onChange={setSelectedFruit} // Parent handles updates
    />
  );
}

Why It Works: The hook adapts to controlled/uncontrolled modes, making it compatible with form libraries like Formik or React Hook Form.

Confusion 5: Resetting Dropdown Values#

The Problem: How do you programmatically reset the dropdown to its initial state (e.g., after form submission)?

The Solution: Add a reset function to the hook that resets selectedValue to the validated initial value.

Enhanced Hook Code:

function useDropdown(initialValue, options) {
  const isValidInitialValue = options.some(option => option.value === initialValue);
  const safeInitialValue = isValidInitialValue ? initialValue : null;
 
  const [selectedValue, setSelectedValue] = useState(safeInitialValue);
 
  const handleChange = (e) => {
    setSelectedValue(e.target.value);
  };
 
  const reset = () => {
    setSelectedValue(safeInitialValue);
  };
 
  return { selectedValue, handleChange, options, reset };
}

Usage Example:

function FruitForm() {
  const { selectedValue, handleChange, reset } = useDropdown(null, fruitOptions);
 
  const handleSubmit = (e) => {
    e.preventDefault();
    alert(`Selected: ${selectedValue}`);
    reset(); // Reset after submission
  };
 
  return (
    <form onSubmit={handleSubmit}>
      <select value={selectedValue} onChange={handleChange}>
        {options.map(option => (
          <option key={option.value} value={option.value}>
            {option.label}
          </option>
        ))}
      </select>
      <button type="submit">Submit</button>
    </form>
  );
}

Why It Works: The reset function reverts selectedValue to the validated initial value, ensuring consistency.

Practical Example: Building useDropdown from Scratch#

Let’s combine all the fixes above into a production-ready useDropdown hook, then use it in a component.

Step 1: The Final Hook#

import { useState, useEffect } from 'react';
 
function useDropdown(initialValue, options, { value: externalValue, onChange: externalOnChange } = {}) {
  // 1. Validate options structure
  options.forEach((option, index) => {
    if (typeof option !== 'object' || !option.label || !option.value) {
      throw new Error(`Invalid option at index ${index}: must be { label: string, value: string }`);
    }
  });
 
  // 2. Validate initial value
  const isValidInitialValue = options.some(option => option.value === initialValue);
  const safeInitialValue = isValidInitialValue ? initialValue : null;
 
  // 3. Support controlled/uncontrolled modes
  const [internalValue, setInternalValue] = useState(safeInitialValue);
  const selectedValue = externalValue !== undefined ? externalValue : internalValue;
 
  // 4. Re-validate on options change
  useEffect(() => {
    const isValid = options.some(option => option.value === selectedValue);
    if (!isValid && externalValue === undefined) {
      setInternalValue(null); // Reset internal state if invalid
    }
  }, [options, selectedValue, externalValue]);
 
  // 5. Handle changes
  const handleChange = (e) => {
    const newValue = e.target.value;
    if (externalValue === undefined) {
      setInternalValue(newValue); // Update internal state (uncontrolled)
    }
    if (externalOnChange) {
      externalOnChange(newValue); // Notify parent (controlled)
    }
  };
 
  // 6. Reset function
  const reset = () => {
    if (externalValue === undefined) {
      setInternalValue(safeInitialValue);
    }
  };
 
  return { selectedValue, handleChange, options, reset };
}
 
export default useDropdown;

Step 2: Using the Hook in a Component#

Let’s build a "User Profile" form with two dropdowns: "Country" and "Language."

import useDropdown from './useDropdown';
 
// Define options (standard structure)
const countryOptions = [
  { label: 'United States', value: 'us' },
  { label: 'Canada', value: 'ca' },
  { label: 'United Kingdom', value: 'uk' },
];
 
const languageOptions = [
  { label: 'English', value: 'en' },
  { label: 'Spanish', value: 'es' },
  { label: 'French', value: 'fr' },
];
 
function UserProfileForm() {
  // Initialize dropdowns with useDropdown
  const {
    selectedValue: country,
    handleChange: handleCountryChange,
    reset: resetCountry,
  } = useDropdown('us', countryOptions);
 
  const {
    selectedValue: language,
    handleChange: handleLanguageChange,
    reset: resetLanguage,
  } = useDropdown(null, languageOptions);
 
  const handleSubmit = (e) => {
    e.preventDefault();
    alert(`Country: ${country}, Language: ${language}`);
    resetCountry();
    resetLanguage();
  };
 
  return (
    <form onSubmit={handleSubmit}>
      <div>
        <label>Country:</label>
        <select value={country} onChange={handleCountryChange}>
          <option value={null}>Select a country</option> {/* Empty default */}
          {countryOptions.map((option) => (
            <option key={option.value} value={option.value}>
              {option.label}
            </option>
          ))}
        </select>
      </div>
 
      <div>
        <label>Language:</label>
        <select value={language} onChange={handleLanguageChange}>
          <option value={null}>Select a language</option>
          {languageOptions.map((option) => (
            <option key={option.value} value={option.value}>
              {option.label}
            </option>
          ))}
        </select>
      </div>
 
      <button type="submit">Save Profile</button>
    </form>
  );
}

Result: Two reusable dropdowns with consistent behavior—validation, reset, and type safety.

Advanced Use Cases#

Multi-Select Support#

To handle multi-select dropdowns (hold Ctrl to select multiple options), modify the hook to track an array of values:

function useMultiSelectDropdown(initialValues = [], options) {
  const [selectedValues, setSelectedValues] = useState(initialValues);
 
  const handleChange = (e) => {
    const options = Array.from(e.target.selectedOptions);
    const newValues = options.map(option => option.value);
    setSelectedValues(newValues);
  };
 
  return { selectedValues, handleChange, options };
}

Integration with Form Libraries#

useDropdown works seamlessly with form libraries like React Hook Form:

import { useForm } from 'react-hook-form';
 
function FormWithReactHookForm() {
  const { register, handleSubmit } = useForm();
  
  // Use useDropdown in controlled mode with React Hook Form
  const { selectedValue, handleChange } = useDropdown(null, countryOptions, {
    value: register('country').value, // Controlled by form state
    onChange: (value) => register('country').onChange({ target: { name: 'country', value } }),
  });
 
  return (
    <form onSubmit={handleSubmit(data => console.log(data))}>
      <select {...register('country')} value={selectedValue} onChange={handleChange}>
        {/* ... options ... */}
      </select>
    </form>
  );
}

Best Practices#

  1. Keep It Focused: useDropdown should manage selection logic, not rendering (e.g., custom dropdown UIs with modals). Let components handle rendering.
  2. Memoize Options: If options don’t change often, wrap them in useMemo to avoid re-validating on every render:
    const options = useMemo(() => [{ label: 'A', value: 'a' }], []); // Stable reference
  3. Test Edge Cases: Write tests for invalid initial values, empty options, and controlled/uncontrolled modes.
  4. Document: Add JSDoc comments to explain parameters (e.g., @param {string} initialValue - The default selected value).

Conclusion#

useDropdown transforms messy, repetitive dropdown logic into a clean, reusable hook. By addressing common confusions—like initial value validation, consistent options, and controlled/uncontrolled modes—you can build dropdowns that work reliably across your app.

Whether you’re building a simple form or a complex dashboard, useDropdown ensures your dropdowns are maintainable, flexible, and easy to debug.

References#