Skip to main content
website logo savvydev
Mastering React Hooks: Beyond useState and useEffect

Mastering React Hooks: Beyond useState and useEffect

Learn advanced React hooks patterns that will make you a better developer. From custom hooks to performance optimization, discover the patterns that senior developers use every day.

React Hooks JavaScript Frontend Custom Hooks Performance

React Hooks revolutionized how we write React components, but most developers only scratch the surface. As a senior developer, I’ve seen teams struggle with hooks patterns that could dramatically improve their code quality and performance. Let’s dive deep into the hooks patterns that separate junior developers from seniors.

Table of Contents

Why Hooks Patterns Matter

Hooks are more than just useState and useEffect. They’re a paradigm shift that enables:

  • Reusable logic across components
  • Better performance through optimization
  • Cleaner code with less boilerplate
  • Better testing with isolated logic

The difference between a junior and senior React developer often comes down to how they use hooks.

Custom Hooks: The Secret Weapon

Basic Custom Hook Pattern

// ❌ BAD: Logic scattered across components
function UserProfile() {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    fetchUser()
      .then(setUser)
      .catch(setError)
      .finally(() => setLoading(false));
  }, []);

  if (loading) return <div>Loading...</div>;
  if (error) return <div>Error: {error.message}</div>;
  return <div>{user.name}</div>;
}

// ✅ GOOD: Custom hook encapsulates logic
function useUser(userId) {
  const [state, setState] = useState({
    user: null,
    loading: true,
    error: null,
  });

  useEffect(() => {
    fetchUser(userId)
      .then((user) => setState({ user, loading: false, error: null }))
      .catch((error) => setState({ user: null, loading: false, error }));
  }, [userId]);

  return state;
}

function UserProfile({ userId }) {
  const { user, loading, error } = useUser(userId);

  if (loading) return <div>Loading...</div>;
  if (error) return <div>Error: {error.message}</div>;
  return <div>{user.name}</div>;
}

Advanced Custom Hook: useLocalStorage

function useLocalStorage(key, 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;
    }
  });

  const setValue = useCallback(
    (value) => {
      try {
        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],
  );

  return [storedValue, setValue];
}

// Usage
function ThemeToggle() {
  const [theme, setTheme] = useLocalStorage("theme", "light");

  return (
    <button onClick={() => setTheme(theme === "light" ? "dark" : "light")}>
      Current theme: {theme}
    </button>
  );
}

Performance Hooks: useMemo, useCallback, useRef

useMemo: Expensive Calculations

// ❌ BAD: Recalculates on every render
function ExpensiveComponent({ items, filter }) {
  const filteredItems = items.filter((item) =>
    item.name.toLowerCase().includes(filter.toLowerCase()),
  );

  const expensiveResult = filteredItems.reduce((acc, item) => {
    // Expensive calculation
    return acc + complexCalculation(item);
  }, 0);

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

// ✅ GOOD: Memoized expensive calculations
function ExpensiveComponent({ items, filter }) {
  const filteredItems = useMemo(
    () =>
      items.filter((item) =>
        item.name.toLowerCase().includes(filter.toLowerCase()),
      ),
    [items, filter],
  );

  const expensiveResult = useMemo(
    () =>
      filteredItems.reduce((acc, item) => {
        return acc + complexCalculation(item);
      }, 0),
    [filteredItems],
  );

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

useCallback: Stable Function References

// ❌ BAD: New function on every render
function ParentComponent() {
  const [count, setCount] = useState(0);

  const handleClick = () => {
    console.log("Button clicked");
  };

  return (
    <div>
      <button onClick={() => setCount(count + 1)}>Count: {count}</button>
      <ExpensiveChild onAction={handleClick} />
    </div>
  );
}

// ✅ GOOD: Stable function reference
function ParentComponent() {
  const [count, setCount] = useState(0);

  const handleClick = useCallback(() => {
    console.log("Button clicked");
  }, []); // Empty dependency array = stable reference

  return (
    <div>
      <button onClick={() => setCount(count + 1)}>Count: {count}</button>
      <ExpensiveChild onAction={handleClick} />
    </div>
  );
}

useRef: Beyond DOM References

// useRef for previous values
function usePrevious(value) {
  const ref = useRef();

  useEffect(() => {
    ref.current = value;
  });

  return ref.current;
}

// useRef for instance variables
function Timer() {
  const [count, setCount] = useState(0);
  const intervalRef = useRef(null);

  useEffect(() => {
    intervalRef.current = setInterval(() => {
      setCount((c) => c + 1);
    }, 1000);

    return () => clearInterval(intervalRef.current);
  }, []);

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

Advanced Patterns

Compound Hooks

function useCounter(initialValue = 0) {
  const [count, setCount] = useState(initialValue);

  const increment = useCallback(() => setCount((c) => c + 1), []);
  const decrement = useCallback(() => setCount((c) => c - 1), []);
  const reset = useCallback(() => setCount(initialValue), [initialValue]);

  return {
    count,
    increment,
    decrement,
    reset,
    isZero: count === 0,
  };
}

// Usage
function Counter() {
  const { count, increment, decrement, reset, isZero } = useCounter(10);

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={increment}>+</button>
      <button onClick={decrement}>-</button>
      <button onClick={reset}>Reset</button>
      {isZero && <p>Count is zero!</p>}
    </div>
  );
}

Async Hooks

function useAsync(asyncFn, immediate = true) {
  const [status, setStatus] = useState("idle");
  const [data, setData] = useState(null);
  const [error, setError] = useState(null);

  const execute = useCallback(
    async (...args) => {
      setStatus("pending");
      setData(null);
      setError(null);

      try {
        const response = await asyncFn(...args);
        setData(response);
        setStatus("success");
      } catch (err) {
        setError(err);
        setStatus("error");
      }
    },
    [asyncFn],
  );

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

  return { execute, status, data, error };
}

// Usage
function UserList() {
  const { data: users, status, error } = useAsync(fetchUsers);

  if (status === "pending") return <div>Loading...</div>;
  if (status === "error") return <div>Error: {error.message}</div>;
  if (status === "success") {
    return (
      <ul>
        {users.map((user) => (
          <li key={user.id}>{user.name}</li>
        ))}
      </ul>
    );
  }
}

State Machines with Hooks

function useReducer(reducer, initialState) {
  const [state, dispatch] = useReducer(reducer, initialState);

  const actions = useMemo(
    () => ({
      start: () => dispatch({ type: "START" }),
      success: (data) => dispatch({ type: "SUCCESS", payload: data }),
      error: (error) => dispatch({ type: "ERROR", payload: error }),
      reset: () => dispatch({ type: "RESET" }),
    }),
    [],
  );

  return [state, actions];
}

const fetchReducer = (state, action) => {
  switch (action.type) {
    case "START":
      return { ...state, status: "loading", error: null };
    case "SUCCESS":
      return { ...state, status: "success", data: action.payload };
    case "ERROR":
      return { ...state, status: "error", error: action.payload };
    case "RESET":
      return { status: "idle", data: null, error: null };
    default:
      return state;
  }
};

Common Pitfalls to Avoid

1. Infinite Re-renders

// ❌ BAD: Infinite re-renders
function BadComponent() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    setCount(count + 1); // Triggers re-render, which triggers useEffect again
  });

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

// ✅ GOOD: Proper dependencies
function GoodComponent() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    setCount(count + 1);
  }, []); // Empty dependency array = run once

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

2. Stale Closures

// ❌ BAD: Stale closure
function BadTimer() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const interval = setInterval(() => {
      setCount(count + 1); // Always uses initial count value
    }, 1000);

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

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

// ✅ GOOD: Functional update
function GoodTimer() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const interval = setInterval(() => {
      setCount((c) => c + 1); // Uses current count value
    }, 1000);

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

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

3. Over-optimization

// ❌ BAD: Premature optimization
function OverOptimizedComponent({ items }) {
  const processedItems = useMemo(
    () => items.map((item) => ({ ...item, processed: true })),
    [items],
  );

  const handleClick = useCallback(() => {
    console.log("clicked");
  }, []);

  return (
    <div>
      {processedItems.map((item) => (
        <button key={item.id} onClick={handleClick}>
          {item.name}
        </button>
      ))}
    </div>
  );
}

// ✅ GOOD: Optimize when needed
function OptimizedComponent({ items }) {
  // Only memoize if items array is large or processing is expensive
  const processedItems =
    items.length > 1000
      ? useMemo(
          () => items.map((item) => ({ ...item, processed: true })),
          [items],
        )
      : items.map((item) => ({ ...item, processed: true }));

  return (
    <div>
      {processedItems.map((item) => (
        <button key={item.id} onClick={() => console.log("clicked")}>
          {item.name}
        </button>
      ))}
    </div>
  );
}

Real-World Examples

E-commerce Product Filter

function useProductFilter(products) {
  const [filters, setFilters] = useState({
    category: "all",
    priceRange: [0, 1000],
    sortBy: "name",
  });

  const filteredProducts = useMemo(() => {
    return products
      .filter(
        (product) =>
          filters.category === "all" || product.category === filters.category,
      )
      .filter(
        (product) =>
          product.price >= filters.priceRange[0] &&
          product.price <= filters.priceRange[1],
      )
      .sort((a, b) => {
        if (filters.sortBy === "name") return a.name.localeCompare(b.name);
        if (filters.sortBy === "price") return a.price - b.price;
        return 0;
      });
  }, [products, filters]);

  const updateFilter = useCallback((key, value) => {
    setFilters((prev) => ({ ...prev, [key]: value }));
  }, []);

  return {
    filters,
    filteredProducts,
    updateFilter,
  };
}

Best Practices

1. Hook Naming Convention

// Always start with "use"
function useUserData() {
  /* ... */
}
function useLocalStorage() {
  /* ... */
}
function useAsyncOperation() {
  /* ... */
}

2. Dependency Arrays

// Be explicit about dependencies
useEffect(() => {
  // effect logic
}, [dependency1, dependency2]); // List all dependencies

// Use ESLint rule: react-hooks/exhaustive-deps

3. Custom Hook Composition

// Compose hooks for complex logic
function useUserProfile(userId) {
  const { data: user, loading, error } = useUser(userId);
  const { data: posts } = useUserPosts(userId);
  const { data: followers } = useUserFollowers(userId);

  return {
    user,
    posts,
    followers,
    loading: loading || !user || !posts || !followers,
    error,
  };
}

Next Steps

1. Advanced Hook Libraries

2. Performance Monitoring

3. Testing Custom Hooks

import { renderHook, act } from "@testing-library/react";

test("useCounter increments correctly", () => {
  const { result } = renderHook(() => useCounter(0));

  act(() => {
    result.current.increment();
  });

  expect(result.current.count).toBe(1);
});

Conclusion

Mastering React hooks patterns is what separates junior developers from seniors. The key is understanding not just how to use hooks, but when and why to use them.

Key Takeaways for Junior Developers:

  1. Custom hooks are your friend - Extract reusable logic
  2. Performance matters - Use useMemo and useCallback wisely
  3. Avoid common pitfalls - Stale closures, infinite re-renders
  4. Compose hooks - Build complex logic from simple hooks
  5. Test your hooks - Ensure reliability and maintainability

Remember: Hooks are not just a feature, they’re a mindset. Think in terms of reusable, composable logic rather than component-specific code.

Related Articles:

Start building better React applications today! 🚀