Back to Blog

React Hooks Mastery: Beyond useState and useEffect

A
Allie
March 15, 2024
reacthooksjavascriptfrontendperformancetutorial
React Hooks Mastery: Beyond useState and useEffect

React Hooks Mastery: Beyond useState and useEffect

React Hooks revolutionized how we write React components, but many developers only scratch the surface. Let's explore advanced patterns and optimization techniques that will elevate your React applications.

Understanding Hook Dependencies

The foundation of effective hook usage lies in understanding dependencies. React's built-in hooks like useEffect, useCallback, and useMemo rely on dependency arrays to determine when to re-run.

The Dependency Array Deep Dive

// ❌ Missing dependencies - potential bugs
useEffect(() => {
  fetchUserData(userId, apiKey);
}, []); // userId and apiKey should be in dependencies

// ✅ Correct dependencies
useEffect(() => {
  fetchUserData(userId, apiKey);
}, [userId, apiKey]);

// ✅ Alternative with exhaustive-deps ESLint rule
useEffect(() => {
  const fetchData = async () => {
    const data = await fetchUserData(userId, apiKey);
    setUserData(data);
  };
  fetchData();
}, [userId, apiKey]); // ESLint will warn if you miss dependencies

Stable References with useCallback

useCallback memoizes function references, preventing unnecessary re-renders of child components:

const UserProfile = ({ userId, onUserUpdate }) => {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(false);

  // ❌ New function on every render
  const handleUpdateUser = (userData) => {
    setLoading(true);
    updateUser(userId, userData)
      .then((updatedUser) => {
        setUser(updatedUser);
        onUserUpdate(updatedUser);
      })
      .finally(() => setLoading(false));
  };

  // ✅ Memoized function reference
  const handleUpdateUser = useCallback((userData) => {
    setLoading(true);
    updateUser(userId, userData)
      .then((updatedUser) => {
        setUser(updatedUser);
        onUserUpdate(updatedUser);
      })
      .finally(() => setLoading(false));
  }, [userId, onUserUpdate]);

  return (
    <UserForm 
      user={user} 
      loading={loading}
      onSubmit={handleUpdateUser} // Stable reference prevents UserForm re-renders
    />
  );
};

Advanced Custom Hooks

Custom hooks are where React's composability truly shines. Let's build some powerful, reusable hooks.

useLocalStorage Hook

function useLocalStorage(key, initialValue) {
  // Get value from localStorage or use initialValue
  const [storedValue, setStoredValue] = useState(() => {
    try {
      const item = window.localStorage.getItem(key);
      return item ? JSON.parse(item) : initialValue;
    } catch (error) {
      console.error(`Error reading localStorage key "${key}":`, error);
      return initialValue;
    }
  });

  // Memoized setter function
  const setValue = useCallback((value) => {
    try {
      // Allow value to be a function so we have the same API as useState
      const valueToStore = value instanceof Function ? value(storedValue) : value;
      setStoredValue(valueToStore);
      window.localStorage.setItem(key, JSON.stringify(valueToStore));
    } catch (error) {
      console.error(`Error setting localStorage key "${key}":`, error);
    }
  }, [key, storedValue]);

  // Listen for changes in other tabs/windows
  useEffect(() => {
    const handleStorageChange = (e) => {
      if (e.key === key && e.newValue) {
        setStoredValue(JSON.parse(e.newValue));
      }
    };

    window.addEventListener('storage', handleStorageChange);
    return () => window.removeEventListener('storage', handleStorageChange);
  }, [key]);

  return [storedValue, setValue];
}

// Usage
const UserSettings = () => {
  const [theme, setTheme] = useLocalStorage('theme', 'light');
  const [preferences, setPreferences] = useLocalStorage('userPrefs', {});

  return (
    <div>
      <button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>
        Toggle Theme ({theme})
      </button>
    </div>
  );
};

useAsync Hook for Data Fetching

function useAsync(asyncFunction, dependencies = []) {
  const [state, setState] = useState({
    data: null,
    loading: true,
    error: null
  });

  const execute = useCallback(async (...args) => {
    setState(prev => ({ ...prev, loading: true, error: null }));
    
    try {
      const data = await asyncFunction(...args);
      setState({ data, loading: false, error: null });
      return data;
    } catch (error) {
      setState({ data: null, loading: false, error });
      throw error;
    }
  }, dependencies);

  useEffect(() => {
    execute();
  }, [execute]);

  return {
    ...state,
    execute
  };
}

// Usage
const UserProfile = ({ userId }) => {
  const {
    data: user,
    loading,
    error,
    execute: refetchUser
  } = useAsync(
    () => fetchUser(userId),
    [userId]
  );

  if (loading) return <Spinner />;
  if (error) return <ErrorMessage error={error} onRetry={refetchUser} />;
  if (!user) return <div>No user found</div>;

  return <UserCard user={user} />;
};

Performance Optimization Patterns

Smart Memoization with useMemo

useMemo should be used judiciously. Here's when and how to use it effectively:

const ProductList = ({ products, filters, sortBy }) => {
  // ✅ Expensive computation that depends on props
  const filteredAndSortedProducts = useMemo(() => {
    console.log('Filtering and sorting products...'); // This should run sparingly
    
    return products
      .filter(product => {
        return Object.entries(filters).every(([key, value]) => 
          !value || product[key] === value
        );
      })
      .sort((a, b) => {
        switch (sortBy) {
          case 'price':
            return a.price - b.price;
          case 'name':
            return a.name.localeCompare(b.name);
          case 'rating':
            return b.rating - a.rating;
          default:
            return 0;
        }
      });
  }, [products, filters, sortBy]);

  // ✅ Memoize expensive derived data
  const productStats = useMemo(() => ({
    total: filteredAndSortedProducts.length,
    avgPrice: filteredAndSortedProducts.reduce((sum, p) => sum + p.price, 0) / filteredAndSortedProducts.length,
    categories: [...new Set(filteredAndSortedProducts.map(p => p.category))]
  }), [filteredAndSortedProducts]);

  return (
    <div>
      <ProductStats stats={productStats} />
      <div className="product-grid">
        {filteredAndSortedProducts.map(product => (
          <ProductCard key={product.id} product={product} />
        ))}
      </div>
    </div>
  );
};

Optimizing Context Usage

Context can cause performance issues if not used carefully:

// ✅ Split contexts by update frequency
const UserContext = createContext();
const UserActionsContext = createContext();

function UserProvider({ children }) {
  const [user, setUser] = useState(null);
  
  // Memoize actions to prevent context consumers from re-rendering
  const actions = useMemo(() => ({
    login: async (credentials) => {
      const userData = await login(credentials);
      setUser(userData);
    },
    logout: () => {
      logout();
      setUser(null);
    },
    updateProfile: async (profileData) => {
      const updatedUser = await updateUserProfile(user.id, profileData);
      setUser(updatedUser);
    }
  }), [user?.id]); // Only recreate when user ID changes

  return (
    <UserContext.Provider value={user}>
      <UserActionsContext.Provider value={actions}>
        {children}
      </UserActionsContext.Provider>
    </UserContext.Provider>
  );
}

// Custom hooks for consuming context
function useUser() {
  const user = useContext(UserContext);
  if (user === undefined) {
    throw new Error('useUser must be used within a UserProvider');
  }
  return user;
}

function useUserActions() {
  const actions = useContext(UserActionsContext);
  if (actions === undefined) {
    throw new Error('useUserActions must be used within a UserProvider');
  }
  return actions;
}

Advanced Hook Patterns

Compound Custom Hooks

Create hooks that work together for complex state management:

// Base hook for form state
function useFormState(initialValues) {
  const [values, setValues] = useState(initialValues);
  const [errors, setErrors] = useState({});
  const [touched, setTouched] = useState({});

  const setValue = useCallback((name, value) => {
    setValues(prev => ({ ...prev, [name]: value }));
  }, []);

  const setError = useCallback((name, error) => {
    setErrors(prev => ({ ...prev, [name]: error }));
  }, []);

  const setTouched = useCallback((name) => {
    setTouched(prev => ({ ...prev, [name]: true }));
  }, []);

  const reset = useCallback(() => {
    setValues(initialValues);
    setErrors({});
    setTouched({});
  }, [initialValues]);

  return {
    values,
    errors,
    touched,
    setValue,
    setError,
    setTouched,
    reset
  };
}

// Validation hook that extends form state
function useFormValidation(formState, validationSchema) {
  const { values, setError } = formState;

  const validate = useCallback(() => {
    const newErrors = {};
    let isValid = true;

    Object.entries(validationSchema).forEach(([field, validators]) => {
      const value = values[field];
      
      for (const validator of validators) {
        const error = validator(value, values);
        if (error) {
          newErrors[field] = error;
          isValid = false;
          break;
        }
      }
    });

    // Update errors for all fields at once
    Object.keys(validationSchema).forEach(field => {
      setError(field, newErrors[field] || null);
    });

    return isValid;
  }, [values, validationSchema, setError]);

  return { validate };
}

// Usage
const ContactForm = () => {
  const formState = useFormState({
    name: '',
    email: '',
    message: ''
  });

  const { validate } = useFormValidation(formState, {
    name: [(value) => !value ? 'Name is required' : null],
    email: [
      (value) => !value ? 'Email is required' : null,
      (value) => !/\S+@\S+\.\S+/.test(value) ? 'Invalid email format' : null
    ],
    message: [
      (value) => !value ? 'Message is required' : null,
      (value) => value.length < 10 ? 'Message must be at least 10 characters' : null
    ]
  });

  const handleSubmit = async (e) => {
    e.preventDefault();
    
    if (!validate()) {
      return;
    }

    try {
      await submitContactForm(formState.values);
      formState.reset();
    } catch (error) {
      console.error('Submission failed:', error);
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      <FormField 
        name="name"
        placeholder="Your name"
        formState={formState}
      />
      <FormField 
        name="email"
        type="email"
        placeholder="your.email@example.com"
        formState={formState}
      />
      <FormField 
        name="message"
        as="textarea"
        placeholder="Your message..."
        formState={formState}
      />
      <button type="submit">Send Message</button>
    </form>
  );
};

Hook Testing Strategies

Testing custom hooks properly ensures they work in isolation and with components:

import { renderHook, act } from '@testing-library/react';
import { useLocalStorage } from '../hooks/useLocalStorage';

describe('useLocalStorage', () => {
  beforeEach(() => {
    localStorage.clear();
  });

  it('should initialize with initial value', () => {
    const { result } = renderHook(() => useLocalStorage('test', 'initial'));
    
    expect(result.current[0]).toBe('initial');
  });

  it('should update localStorage when value changes', () => {
    const { result } = renderHook(() => useLocalStorage('test', 'initial'));
    
    act(() => {
      result.current[1]('updated');
    });

    expect(result.current[0]).toBe('updated');
    expect(localStorage.getItem('test')).toBe('"updated"');
  });

  it('should handle localStorage errors gracefully', () => {
    // Mock localStorage to throw an error
    const setItemSpy = jest.spyOn(localStorage, 'setItem')
      .mockImplementation(() => {
        throw new Error('Quota exceeded');
      });

    const { result } = renderHook(() => useLocalStorage('test', 'initial'));
    
    // Should not throw when setting value
    expect(() => {
      act(() => {
        result.current[1]('new value');
      });
    }).not.toThrow();

    setItemSpy.mockRestore();
  });
});

Common Pitfalls and Solutions

Stale Closures

// ❌ Stale closure problem
function Counter() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const timer = setInterval(() => {
      setCount(count + 1); // This captures the initial count value (0)
    }, 1000);

    return () => clearInterval(timer);
  }, []); // Empty dependency array means count is stale

  return <div>{count}</div>;
}

// ✅ Solution 1: Use functional state updates
function Counter() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const timer = setInterval(() => {
      setCount(prevCount => prevCount + 1); // Always gets the latest count
    }, 1000);

    return () => clearInterval(timer);
  }, []);

  return <div>{count}</div>;
}

// ✅ Solution 2: Use useRef for mutable values
function Counter() {
  const [count, setCount] = useState(0);
  const countRef = useRef(count);
  
  countRef.current = count; // Keep ref in sync

  useEffect(() => {
    const timer = setInterval(() => {
      setCount(countRef.current + 1);
    }, 1000);

    return () => clearInterval(timer);
  }, []);

  return <div>{count}</div>;
}

Conclusion

Mastering React Hooks goes beyond knowing the basic APIs. It's about understanding dependencies, leveraging memoization effectively, creating reusable custom hooks, and avoiding common pitfalls.

The patterns shown here will help you build more performant, maintainable React applications. Remember: measure first, optimize second. Not every computation needs memoization, and not every callback needs useCallback.

Start with these patterns and adapt them to your specific use cases. Happy hooking!

Author

About Allie

Developer and writer passionate about web technologies and creating user-friendly experiences.

Stay Updated

Subscribe to my newsletter to receive updates on new articles, tutorials, and resources.

Allison's Developer Portfolio

Full-stack developer specializing in modern web technologies

Contact

Feel free to reach out for collaborations or just a friendly hello

dev@allisons.dev

© 2026 Allison's Developer Portfolio. All rights reserved.

Built with using Nuxt, Nuxt UI, and Tailwind CSS