Back to all posts
Web Development
React

Custom React Hooks That Changed Our Development Process

12 Dec 2020
18 min read
Jerry S Joseph
Jerry S Joseph
Full Stack Developer

After leading development teams for over half a decade, I've seen firsthand how small architectural decisions can dramatically impact productivity. Nothing demonstrates this better than our team's journey with custom React hooks.

When we first adopted React at scale, we were building a complex SaaS platform with dozens of interconnected features. Our codebase quickly became a labyrinth of repeated logic, inconsistent state management, and bespoke solutions to common problems. Sound familiar?

Today, I want to share the custom hooks that fundamentally changed how we approach React development—hooks that not only cleaned up our codebase but transformed how our teams collaborate and ship features.

The Problem with Traditional Approaches

Before diving into specific hooks, let's consider a scenario most React developers face: fetching and managing API data. Here's how this might traditionally look:

function ProductList() {
  const [products, setProducts] = useState([]);
  const [isLoading, setIsLoading] = useState(false);
  const [error, setError] = useState(null);
 
  useEffect(() => {
    const fetchProducts = async () => {
      setIsLoading(true);
      try {
        const response = await fetch('/api/products');
        const data = await response.json();
        setProducts(data);
        setError(null);
      } catch (err) {
        setError(err.message);
        setProducts([]);
      } finally {
        setIsLoading(false);
      }
    };
 
    fetchProducts();
  }, []);
 
  if (isLoading) return <div>Loading...</div>;
  if (error) return <div>Error: {error}</div>;
 
  return (
    <div>
      {products.map(product => (
        <ProductCard key={product.id} product={product} />
      ))}
    </div>
  );
}

What's wrong with this approach? Consider the questions:

  1. What happens if we need identical data fetching logic in multiple components?
  2. How do we handle refetching, caching, or pagination consistently?
  3. What about managing loading and error states across complex UI workflows?

This approach clearly doesn't scale as your application grows. Enter custom hooks.

Hook #1: useFetch — Transforming Data Fetching

The first hook that changed everything for us was useFetch. Here's the implementation:

function useFetch(url, options = {}) {
  const [data, setData] = useState(null);
  const [isLoading, setIsLoading] = useState(false);
  const [error, setError] = useState(null);
  const [refetchIndex, setRefetchIndex] = useState(0);
 
  // Cache control
  const { cache = true, dependencies = [] } = options;
  
  const refetch = useCallback(() => {
    setRefetchIndex(prev => prev + 1);
  }, []);
 
  useEffect(() => {
    // Don't fetch if no URL provided
    if (!url) return;
    
    let isCancelled = false;
    setIsLoading(true);
 
    const fetchData = async () => {
      try {
        const response = await fetch(url, options.fetchOptions);
        
        if (!response.ok) {
          throw new Error(`Error ${response.status}: ${response.statusText}`);
        }
        
        const result = await response.json();
        
        if (!isCancelled) {
          setData(result);
          setError(null);
        }
      } catch (err) {
        if (!isCancelled) {
          setError(err.message);
          setData(null);
        }
      } finally {
        if (!isCancelled) {
          setIsLoading(false);
        }
      }
    };
 
    fetchData();
 
    // Cleanup function to handle component unmounting
    return () => {
      isCancelled = true;
    };
  }, [url, refetchIndex, ...dependencies]);
 
  return { data, isLoading, error, refetch };
}

Now our component becomes:

function ProductList() {
  const { data: products, isLoading, error } = useFetch('/api/products');
 
  if (isLoading) return <div>Loading...</div>;
  if (error) return <div>Error: {error}</div>;
 
  return (
    <div>
      {products?.map(product => (
        <ProductCard key={product.id} product={product} />
      ))}
    </div>
  );
}

The transformation is remarkable. Not only is our component significantly cleaner, but we've also:

  • Abstracted away all the fetching, loading, and error handling logic
  • Added request cancellation to prevent memory leaks
  • Included a refetch function for manual refreshes
  • Added dependency tracking to allow refetching based on state changes

This hook alone eliminated thousands of lines of duplicated code across our application.

Hook #2: useLocalStorage — Persistent State Management

Another game-changer was our useLocalStorage hook. The problem? React's state is ephemeral—refresh the page, and it's gone. While solutions like Redux persist to localStorage, they add complexity for simple cases.

function useLocalStorage(key, initialValue) {
  // State to store our value
  const [storedValue, setStoredValue] = useState(() => {
    try {
      // Get from local storage by key
      const item = window.localStorage.getItem(key);
      // Parse stored json or if none return initialValue
      return item ? JSON.parse(item) : initialValue;
    } catch (error) {
      // If error also return initialValue
      console.error(error);
      return initialValue;
    }
  });
 
  // Return a wrapped version of useState's setter function that
  // persists the new value to localStorage.
  const setValue = value => {
    try {
      // Allow value to be a function so we have same API as useState
      const valueToStore =
        value instanceof Function ? value(storedValue) : value;
      // Save state
      setStoredValue(valueToStore);
      // Save to local storage
      window.localStorage.setItem(key, JSON.stringify(valueToStore));
    } catch (error) {
      // A more advanced implementation would handle the error case
      console.error(error);
    }
  };
 
  return [storedValue, setValue];
}

Using this hook is as simple as replacing useState:

function ThemeSelector() {
  const [theme, setTheme] = useLocalStorage('theme', 'light');
 
  return (
    <select 
      value={theme} 
      onChange={e => setTheme(e.target.value)}
    >
      <option value="light">Light</option>
      <option value="dark">Dark</option>
      <option value="system">System</option>
    </select>
  );
}

Now our theme preference persists across page refreshes—no additional libraries required. This pattern proved invaluable for user preferences, form data, and application state that needed to survive navigation.

Hook #3: useMediaQuery — Responsive Design in React

Responsive design has traditionally been the domain of CSS, but what about responsive behavior? Our useMediaQuery hook bridged this gap:

function useMediaQuery(query) {
  const [matches, setMatches] = useState(false);
 
  useEffect(() => {
    const media = window.matchMedia(query);
    
    // Update the state initially
    setMatches(media.matches);
    
    // Define callback for media query changes
    const listener = (event) => {
      setMatches(event.matches);
    };
    
    // Add the callback as a listener
    media.addEventListener('change', listener);
    
    // Remove the listener on cleanup
    return () => {
      media.removeEventListener('change', listener);
    };
  }, [query]);
 
  return matches;
}

This simple hook enables responsive behavior in component logic:

function ResponsiveNavigation() {
  const isMobile = useMediaQuery('(max-width: 768px)');
 
  return (
    <nav>
      {isMobile ? (
        <MobileMenu />
      ) : (
        <DesktopMenu />
      )}
    </nav>
  );
}

This hook eliminated countless bugs related to window resizing and hydration mismatches between server and client. Beyond that, it allowed us to create truly adaptive UIs that respond not just in appearance but in functionality to the user's context.

Hook #4: useDebounce — Performance Optimization

Real-time search, form validation, and API calls during typing can create performance issues. Our useDebounce hook tackled this elegantly:

function useDebounce(value, delay) {
  const [debouncedValue, setDebouncedValue] = useState(value);
 
  useEffect(() => {
    // Set debouncedValue to value after specified delay
    const handler = setTimeout(() => {
      setDebouncedValue(value);
    }, delay);
 
    // Cancel the timeout if value changes or component unmounts
    return () => {
      clearTimeout(handler);
    };
  }, [value, delay]);
 
  return debouncedValue;
}

This hook transformed our search components:

function SearchBar() {
  const [searchTerm, setSearchTerm] = useState('');
  const debouncedSearchTerm = useDebounce(searchTerm, 500);
  const { data, isLoading } = useFetch(
    debouncedSearchTerm ? `/api/search?q=${debouncedSearchTerm}` : null
  );
 
  return (
    <div>
      <input
        type="text"
        value={searchTerm}
        onChange={e => setSearchTerm(e.target.value)}
        placeholder="Search..."
      />
      {isLoading && <span>Loading...</span>}
      {data && (
        <ul>
          {data.results.map(item => (
            <li key={item.id}>{item.name}</li>
          ))}
        </ul>
      )}
    </div>
  );
}

This pattern eliminated unnecessary network requests and prevented UI jank during typing. After implementing this hook, our search performance dramatically improved, and our backend servers thanked us for the reduced load.

Hook #5: useForm — Simplified Form Management

Forms in React can be verbose and error-prone. While libraries like Formik and React Hook Form exist, for our specific needs, we created a lighter-weight useForm hook:

function useForm(initialValues, onSubmit, validate) {
  const [values, setValues] = useState(initialValues);
  const [errors, setErrors] = useState({});
  const [touched, setTouched] = useState({});
  const [isSubmitting, setIsSubmitting] = useState(false);
 
  // Reset form to initial values
  const resetForm = useCallback(() => {
    setValues(initialValues);
    setErrors({});
    setTouched({});
    setIsSubmitting(false);
  }, [initialValues]);
 
  // Update form values
  const handleChange = useCallback((event) => {
    const { name, value } = event.target;
    setValues(prevValues => ({
      ...prevValues,
      [name]: value
    }));
  }, []);
 
  // Mark field as touched on blur
  const handleBlur = useCallback((event) => {
    const { name } = event.target;
    setTouched(prevTouched => ({
      ...prevTouched,
      [name]: true
    }));
  }, []);
 
  // Validate form on submit
  const handleSubmit = useCallback(async (event) => {
    if (event) event.preventDefault();
    
    // Mark all fields as touched
    const allTouched = Object.keys(initialValues).reduce((acc, key) => {
      acc[key] = true;
      return acc;
    }, {});
    setTouched(allTouched);
    
    // Validate if function provided
    if (validate) {
      const validationErrors = validate(values);
      setErrors(validationErrors);
      
      // Don't submit if there are errors
      if (Object.keys(validationErrors).length > 0) return;
    }
    
    setIsSubmitting(true);
    
    try {
      await onSubmit(values);
    } catch (error) {
      console.error('Form submission error:', error);
    } finally {
      setIsSubmitting(false);
    }
  }, [initialValues, onSubmit, validate, values]);
 
  return {
    values,
    errors,
    touched,
    isSubmitting,
    handleChange,
    handleBlur,
    handleSubmit,
    resetForm
  };
}

This hook transformed our form components:

function SignupForm() {
  const validate = (values) => {
    const errors = {};
    if (!values.email) {
      errors.email = 'Required';
    } else if (!/\S+@\S+\.\S+/.test(values.email)) {
      errors.email = 'Invalid email';
    }
    if (!values.password) {
      errors.password = 'Required';
    } else if (values.password.length < 8) {
      errors.password = 'Password must be at least 8 characters';
    }
    return errors;
  };
 
  const handleSignup = async (values) => {
    // API call to register user
    await fetch('/api/signup', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(values)
    });
  };
 
  const {
    values,
    errors,
    touched,
    isSubmitting,
    handleChange,
    handleBlur,
    handleSubmit
  } = useForm(
    { email: '', password: '' },
    handleSignup,
    validate
  );
 
  return (
    <form onSubmit={handleSubmit}>
      <div>
        <label>Email</label>
        <input
          type="email"
          name="email"
          value={values.email}
          onChange={handleChange}
          onBlur={handleBlur}
        />
        {touched.email && errors.email && (
          <div className="error">{errors.email}</div>
        )}
      </div>
      
      <div>
        <label>Password</label>
        <input
          type="password"
          name="password"
          value={values.password}
          onChange={handleChange}
          onBlur={handleBlur}
        />
        {touched.password && errors.password && (
          <div className="error">{errors.password}</div>
        )}
      </div>
      
      <button type="submit" disabled={isSubmitting}>
        {isSubmitting ? 'Signing up...' : 'Sign Up'}
      </button>
    </form>
  );
}

This approach drastically simplified our form handling while providing validation, touched states, and submission handling in a clean, reusable pattern.

The Impact on Our Development Process

These hooks didn't just clean up our code—they fundamentally changed how we think about and build React applications:

  1. Standardized Patterns: Teams across different projects now speak the same language. When a developer says "we'll use useFetch here," everyone understands the implementation details.

  2. Faster Onboarding: New team members can focus on business logic rather than reinventing utility functions. Our onboarding time decreased by roughly 40%.

  3. Reduced Bugs: By centralizing common functionality, we fix bugs once instead of hunting them down across multiple implementations.

  4. Improved Performance: Hooks like useDebounce and carefully optimized data fetching improved our application performance significantly.

  5. Better Testing: With logic extracted into hooks, we can unit test these utilities separately from the UI components that use them.

Creating Your Own Hook Library

Based on our experience, here are the steps to building your own custom hook library:

  1. Identify Repetitive Patterns: Look for code you're writing again and again across components.

  2. Start Small: Begin with one or two hooks that solve immediate pain points.

  3. Document Well: Each hook should have clear documentation on inputs, outputs, and behavior.

  4. Test Thoroughly: Hooks are pure JavaScript functions and should be unit tested.

  5. Share Knowledge: Build a component library or documentation site that showcases how to use these hooks.

  6. Iterate Based on Feedback: As developers use your hooks, gather feedback and refine them.

Advanced Hook Composition

The true power of hooks comes from composition. Consider this advanced example that combines multiple hooks:

function ProductSearch() {
  // Responsive state
  const isMobile = useMediaQuery('(max-width: 768px)');
  
  // Form state
  const { values, handleChange } = useForm({ search: '' });
  
  // Debounced search term
  const debouncedSearch = useDebounce(values.search, 400);
  
  // Fetch results based on debounced search
  const { data, isLoading } = useFetch(
    debouncedSearch ? `/api/products/search?q=${debouncedSearch}` : null
  );
  
  // Persist view preference
  const [viewMode, setViewMode] = useLocalStorage('productViewMode', 'grid');
 
  return (
    <div className="product-search">
      <div className="search-controls">
        <input
          name="search"
          value={values.search}
          onChange={handleChange}
          placeholder={isMobile ? "Search" : "Search products..."}
          className={isMobile ? "compact-input" : "full-input"}
        />
        
        <button
          onClick={() => setViewMode(viewMode === 'grid' ? 'list' : 'grid')}
          aria-label="Toggle view"
        >
          {viewMode === 'grid' ? 'List View' : 'Grid View'}
        </button>
      </div>
      
      {isLoading ? (
        <LoadingSpinner />
      ) : (
        <ProductDisplay
          products={data?.products || []}
          viewMode={viewMode}
          layout={isMobile ? 'vertical' : 'horizontal'}
        />
      )}
    </div>
  );
}

This component combines five custom hooks into a cohesive, responsive, and performant product search feature with just a few lines of JSX.

Conclusion: Beyond the Hooks

Custom hooks transformed our development process by enforcing consistency, improving performance, and allowing us to focus on product features rather than utility functions. But perhaps their most important impact was cultural: they established a common vocabulary and set of best practices across our organization.

As you evaluate your own React codebase, ask yourself: What patterns do you find yourself repeating? Which pieces of state management are inconsistent across components? What functionality could be abstracted into reusable hooks?

The journey to a more maintainable codebase begins with recognizing these patterns and taking small, deliberate steps toward abstraction. Start with one hook today, and you might be surprised how quickly it spreads throughout your application.

I'd love to hear about your experiences: What custom hooks have transformed your development process, and how did they change the way your team thinks about React architecture?