Skip to main content
    Back to all articles

    Building Custom Hooks for React Applications

    10 min read
    By BAHAJ ABDERRAZAK
    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.

    \n

    Understanding Custom Hooks

    \n

    At their core, custom hooks are JavaScript functions that:

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

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

    \n

    Building a useLocalStorage Hook

    \n

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

    \n
          import { useState, useEffect } from 'react';\n\n      function useLocalStorage(key, initialValue) {\n        // Get stored value\n        const readValue = () => {\n          if (typeof window === 'undefined') {\n            return initialValue;\n          }\n          \n          try {\n            const item = window.localStorage.getItem(key);\n            return item ? JSON.parse(item) : initialValue;\n          } catch (error) {\n            console.warn('Error reading localStorage key', error);\n            return initialValue;\n          }\n        };\n\n        // State to store our value\n        const [storedValue, setStoredValue] = useState(readValue);\n\n        // Return a wrapped version of useState's setter function that\n        // persists the new value to localStorage\n        const setValue = (value) => {\n          try {\n            // Allow value to be a function\n            const valueToStore =\n              value instanceof Function ? value(storedValue) : value;\n              \n            // Save state\n            setStoredValue(valueToStore);\n            \n            // Save to localStorage\n            if (typeof window !== 'undefined') {\n              window.localStorage.setItem(key, JSON.stringify(valueToStore));\n            }\n          } catch (error) {\n            console.warn('Error setting localStorage key', error);\n          }\n        };\n        \n        // Listen for changes to the key in other tabs/windows\n        useEffect(() => {\n          const handleStorageChange = (event) => {\n            if (event.key === key) {\n              setStoredValue(JSON.parse(event.newValue || JSON.stringify(initialValue)));\n            }\n          };\n          \n          window.addEventListener('storage', handleStorageChange);\n          return () => window.removeEventListener('storage', handleStorageChange);\n        }, [key, initialValue]);\n\n        return [storedValue, setValue];\n      }
    \n

    Using this hook is as simple as:

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

    Authentication Hook (useAuth)

    \n

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

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

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

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

    Form Handling with useForm

    \n

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

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

    This hook makes form handling concise and consistent:

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

    API Data Fetching with useAPI

    \n

    Data fetching is a common operation that benefits from hooks:

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

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

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

    Conclusion

    \n

    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.

    \n

    When building custom hooks, remember these best practices:

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

    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.