Skip to main content
    Back to all articles

    Building Custom Hooks for React Applications

    React
    10 min read
    By Thomas Brown
    Featured image for "Building Custom Hooks for React Applications"

    React hooks revolutionized the way we build components by enabling function components to use state and other React features. Custom hooks take this a step further, allowing developers to extract and reuse stateful logic across components. This article explores how to create powerful, reusable custom hooks for common scenarios.

    Understanding Custom Hooks

    At their core, custom hooks are JavaScript functions that:

    • Start with the word 'use' (important for linting and convention)
    • Can call other hooks
    • Extract and reuse stateful logic between components

    The key insight is that hooks allow us to organize code by logical concern rather than lifecycle methods.

    Building a useLocalStorage Hook

    Let's start with a practical example - a hook that synchronizes state with localStorage:

          import { useState, useEffect } from 'react';
    
          function useLocalStorage(key, initialValue) {
            // Get stored value
            const readValue = () => {
              if (typeof window === 'undefined') {
                return initialValue;
              }
              
              try {
                const item = window.localStorage.getItem(key);
                return item ? JSON.parse(item) : initialValue;
              } catch (error) {
                console.warn('Error reading localStorage key', error);
                return initialValue;
              }
            };
    
            // State to store our value
            const [storedValue, setStoredValue] = useState(readValue);
    
            // 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
                const valueToStore =
                  value instanceof Function ? value(storedValue) : value;
                  
                // Save state
                setStoredValue(valueToStore);
                
                // Save to localStorage
                if (typeof window !== 'undefined') {
                  window.localStorage.setItem(key, JSON.stringify(valueToStore));
                }
              } catch (error) {
                console.warn('Error setting localStorage key', error);
              }
            };
            
            // Listen for changes to the key in other tabs/windows
            useEffect(() => {
              const handleStorageChange = (event) => {
                if (event.key === key) {
                  setStoredValue(JSON.parse(event.newValue || JSON.stringify(initialValue)));
                }
              };
              
              window.addEventListener('storage', handleStorageChange);
              return () => window.removeEventListener('storage', handleStorageChange);
            }, [key, initialValue]);
    
            return [storedValue, setValue];
          }

    Using this hook is as simple as:

          function ProfileSettings() {
            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>
            );
          }

    Authentication Hook (useAuth)

    Authentication is a perfect use case for custom hooks, as it's logic that's often needed across many components:

          function useAuth() {
            const [currentUser, setCurrentUser] = useState(null);
            const [loading, setLoading] = useState(true);
            const [error, setError] = useState(null);
    
            // Sign in function
            const signIn = async (email, password) => {
              setLoading(true);
              try {
                // This could be a call to your authentication service
                const user = await authService.login(email, password);
                setCurrentUser(user);
                return user;
              } catch (err) {
                setError(err);
                throw err;
              } finally {
                setLoading(false);
              }
            };
    
            // Sign out function
            const signOut = async () => {
              try {
                await authService.logout();
                setCurrentUser(null);
              } catch (err) {
                setError(err);
                throw err;
              }
            };
    
            // Check if user is already logged in on mount
            useEffect(() => {
              const checkAuth = async () => {
                try {
                  const user = await authService.getCurrentUser();
                  setCurrentUser(user);
                } catch (err) {
                  setError(err);
                } finally {
                  setLoading(false);
                }
              };
              
              checkAuth();
            }, []);
    
            return {
              currentUser,
              loading,
              error,
              signIn,
              signOut,
              isAuthenticated: !!currentUser
            };
          }

    This hook encapsulates all authentication-related logic and state, making it easy to use across your application:

          function LoginPage() {
            const { signIn, loading, error } = useAuth();
            const [email, setEmail] = useState('');
            const [password, setPassword] = useState('');
            
            const handleSubmit = async (e) => {
              e.preventDefault();
              try {
                await signIn(email, password);
                // Redirect after successful login
                navigate('/dashboard');
              } catch (err) {
                // Error is already handled in the hook
                // Login failed - error handling managed by useAuth hook
              }
            };
            
            return (
              <form onSubmit={handleSubmit}>
                {/* Login form fields */}
                {error && <p className="error">{error.message}</p>}
                <button type="submit" disabled={loading}>
                  {loading ? 'Logging in...' : 'Log In'}
                </button>
              </form>
            );
          }

    Form Handling with useForm

    Form handling is another common use case that can be simplified with custom hooks:

          function useForm(initialValues, validate, onSubmit) {
            const [values, setValues] = useState(initialValues);
            const [errors, setErrors] = useState({});
            const [isSubmitting, setIsSubmitting] = useState(false);
            
            useEffect(() => {
              if (isSubmitting) {
                const noErrors = Object.keys(errors).length === 0;
                if (noErrors) {
                  onSubmit(values);
                  setIsSubmitting(false);
                } else {
                  setIsSubmitting(false);
                }
              }
            }, [errors, isSubmitting, onSubmit, values]);
            
            const handleChange = (e) => {
              const { name, value } = e.target;
              setValues({
                ...values,
                [name]: value
              });
            };
            
            const handleSubmit = (e) => {
              e.preventDefault();
              setErrors(validate(values));
              setIsSubmitting(true);
            };
            
            const handleBlur = () => {
              setErrors(validate(values));
            };
            
            return {
              values,
              errors,
              isSubmitting,
              handleChange,
              handleSubmit,
              handleBlur
            };
          }

    This hook makes form handling concise and consistent:

          function ContactForm() {
            const validateForm = (values) => {
              let errors = {};
              
              if (!values.email) {
                errors.email = 'Email is required';
              } else if (!/\S+@\S+\.\S+/.test(values.email)) {
                errors.email = 'Email is invalid';
              }
              
              if (!values.message) {
                errors.message = 'Message is required';
              }
              
              return errors;
            };
            
            const submitForm = async (values) => {
              await apiService.sendContactForm(values);
              alert('Form submitted!');
            };
            
            const {
              values,
              errors,
              isSubmitting,
              handleChange,
              handleSubmit,
              handleBlur
            } = useForm(
              { email: '', message: '' },
              validateForm,
              submitForm
            );
            
            return (
              <form onSubmit={handleSubmit}>
                <div>
                  <label htmlFor="email">Email</label>
                  <input
                    id="email"
                    name="email"
                    value={values.email}
                    onChange={handleChange}
                    onBlur={handleBlur}
                  />
                  {errors.email && <p className="error">{errors.email}</p>}
                </div>
                
                <div>
                  <label htmlFor="message">Message</label>
                  <textarea
                    id="message"
                    name="message"
                    value={values.message}
                    onChange={handleChange}
                    onBlur={handleBlur}
                  />
                  {errors.message && <p className="error">{errors.message}</p>}
                </div>
                
                <button type="submit" disabled={isSubmitting}>
                  Submit
                </button>
              </form>
            );
          }

    API Data Fetching with useAPI

    Data fetching is a common operation that benefits from hooks:

          function useAPI(url) {
            const [data, setData] = useState(null);
            const [isLoading, setIsLoading] = useState(true);
            const [error, setError] = useState(null);
            
            useEffect(() => {
              let isMounted = true;
              
              const fetchData = async () => {
                setIsLoading(true);
                try {
                  const response = await fetch(url);
                  if (!response.ok) {
                    throw new Error('Network response was not ok');
                  }
                  const result = await response.json();
                  
                  if (isMounted) {
                    setData(result);
                    setError(null);
                  }
                } catch (err) {
                  if (isMounted) {
                    setError(err);
                    setData(null);
                  }
                } finally {
                  if (isMounted) {
                    setIsLoading(false);
                  }
                }
              };
              
              fetchData();
              
              return () => {
                isMounted = false;
              };
            }, [url]);
            
            const refetch = useCallback(async () => {
              setIsLoading(true);
              try {
                const response = await fetch(url);
                if (!response.ok) {
                  throw new Error('Network response was not ok');
                }
                const result = await response.json();
                setData(result);
                setError(null);
              } catch (err) {
                setError(err);
              } finally {
                setIsLoading(false);
              }
            }, [url]);
            
            return { data, isLoading, error, refetch };
          }

    Using this hook provides a clean way to handle API calls:

          function UserProfile({ userId }) {
            const { data: user, isLoading, error } = useAPI(`/api/users/${userId}`);
            
            if (isLoading) return <p>Loading...</p>;
            if (error) return <p>Error: {error.message}</p>;
            
            return (
              <div className="user-profile">
                <h2>{user.name}</h2>
                <p>{user.email}</p>
                {/* Additional user details */}
              </div>
            );
          }

    Conclusion

    Custom hooks provide a powerful pattern for sharing logic between React components. They enable better code organization, improved reusability, and more maintainable applications. By extracting common patterns like state management, form handling, authentication, and data fetching into custom hooks, you can significantly simplify your components and create a more consistent codebase.

    When building custom hooks, remember these best practices:

    • Keep hooks focused on a single responsibility
    • Make hooks composable when possible
    • Handle cleanup to prevent memory leaks
    • Provide clear documentation about dependencies and behavior
    • Share common hooks across your organization

    By embracing custom hooks, you'll find that your React components become more concise and easier to understand, focusing on the presentation logic while the complex stateful behavior is neatly encapsulated in your custom hooks.

    Tags

    React
    Custom Hooks
    Frontend
    JavaScript
    Authentication