Custom Hooks: Building Your Own Superpowers

SS Saurav Sitaula

Learn to build custom React hooks that extract and share stateful logic across components. Includes practical examples: useFetch for data fetching, useLocalStorage for persistence, useDebounce for performance, and useOnClickOutside for UI patterns. Complete guide with rules and best practices.

Previously on “The React Learning Journey”

In my last post, I made the leap from class components to hooks. useState and useEffect became my new best friends. No more this, no more binding, no more scattered lifecycle methods.

Life was good.

Then I noticed something: I was copying the same hook logic between components. A lot.

The Copy-Paste Problem Returns

Remember when I was copying navigation bars across HTML files? Well, history was repeating itself:

// In UserProfile.jsx
function UserProfile({ userId }) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    setLoading(true);
    fetch(`/api/users/${userId}`)
      .then(res => res.json())
      .then(data => {
        setUser(data);
        setLoading(false);
      })
      .catch(err => {
        setError(err);
        setLoading(false);
      });
  }, [userId]);

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

// In ProductPage.jsx - basically the same thing!
function ProductPage({ productId }) {
  const [product, setProduct] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    setLoading(true);
    fetch(`/api/products/${productId}`)
      .then(res => res.json())
      .then(data => {
        setProduct(data);
        setLoading(false);
      })
      .catch(err => {
        setError(err);
        setLoading(false);
      });
  }, [productId]);

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

Two components, nearly identical data-fetching logic. And I had this pattern in like 15 places.

There has to be a better way.

The Revelation: Hooks Are Just Functions

Here’s what finally clicked: Custom hooks are just functions that use other hooks.

That’s it. No magic. No special syntax. Just a function that starts with use.

// This is a custom hook
function useFetch(url) {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    setLoading(true);
    fetch(url)
      .then(res => res.json())
      .then(data => {
        setData(data);
        setLoading(false);
      })
      .catch(err => {
        setError(err);
        setLoading(false);
      });
  }, [url]);

  return { data, loading, error };
}

Now those two components become:

function UserProfile({ userId }) {
  const { data: user, loading, error } = useFetch(`/api/users/${userId}`);

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

function ProductPage({ productId }) {
  const { data: product, loading, error } = useFetch(`/api/products/${productId}`);

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

Fifteen components. One hook. Change the fetch logic once, everywhere updates.

This is what I was missing with class components.

The Rules of Custom Hooks

Before I got too excited, I had to learn the rules:

1. Always Start with “use”

// ✅ Good - React knows this is a hook
function useWindowSize() { ... }

// ❌ Bad - React can't enforce hook rules
function getWindowSize() { ... }

The use prefix isn’t just convention. It tells React’s linter to check that you’re following the rules of hooks.

2. Custom Hooks Can Call Other Hooks

function useUserWithPosts(userId) {
  // Call other custom hooks!
  const { data: user, loading: userLoading } = useFetch(`/api/users/${userId}`);
  const { data: posts, loading: postsLoading } = useFetch(`/api/users/${userId}/posts`);

  return {
    user,
    posts,
    loading: userLoading || postsLoading,
  };
}

3. Every Call Gets Its Own State

This confused me at first. If two components use the same custom hook, do they share state?

No. Each call to a custom hook gets its own isolated state.

function App() {
  return (
    <>
      <Counter />  {/* Has its own count state */}
      <Counter />  {/* Has its own count state */}
    </>
  );
}

function Counter() {
  const count = useCounter();  // Each gets independent state
  return <div>{count}</div>;
}

Think of it like calling a function. Each call creates new variables.

My Custom Hook Collection

Over the next few months, I built up a toolkit of hooks I used constantly:

useLocalStorage

Store state that persists across page refreshes:

function useLocalStorage(key, initialValue) {
  const [value, setValue] = useState(() => {
    const stored = localStorage.getItem(key);
    return stored ? JSON.parse(stored) : initialValue;
  });

  useEffect(() => {
    localStorage.setItem(key, JSON.stringify(value));
  }, [key, value]);

  return [value, setValue];
}

// Usage
function Settings() {
  const [darkMode, setDarkMode] = useLocalStorage('darkMode', false);
  
  return (
    <button onClick={() => setDarkMode(!darkMode)}>
      {darkMode ? '🌙' : '☀️'}
    </button>
  );
}

Close the tab. Come back. Your preference is still there.

useDebounce

Wait for the user to stop typing before doing something:

function useDebounce(value, delay) {
  const [debouncedValue, setDebouncedValue] = useState(value);

  useEffect(() => {
    const timer = setTimeout(() => {
      setDebouncedValue(value);
    }, delay);

    return () => clearTimeout(timer);
  }, [value, delay]);

  return debouncedValue;
}

// Usage - search as you type (but not on EVERY keystroke)
function SearchBox() {
  const [query, setQuery] = useState('');
  const debouncedQuery = useDebounce(query, 300);

  useEffect(() => {
    if (debouncedQuery) {
      searchAPI(debouncedQuery);  // Only searches after 300ms of no typing
    }
  }, [debouncedQuery]);

  return <input value={query} onChange={e => setQuery(e.target.value)} />;
}

No more API calls on every single keystroke.

useOnClickOutside

Close a dropdown when clicking outside:

function useOnClickOutside(ref, handler) {
  useEffect(() => {
    const listener = (event) => {
      if (!ref.current || ref.current.contains(event.target)) {
        return;
      }
      handler(event);
    };

    document.addEventListener('mousedown', listener);
    document.addEventListener('touchstart', listener);

    return () => {
      document.removeEventListener('mousedown', listener);
      document.removeEventListener('touchstart', listener);
    };
  }, [ref, handler]);
}

// Usage
function Dropdown() {
  const [isOpen, setIsOpen] = useState(false);
  const ref = useRef();

  useOnClickOutside(ref, () => setIsOpen(false));

  return (
    <div ref={ref}>
      <button onClick={() => setIsOpen(!isOpen)}>Menu</button>
      {isOpen && <div className="dropdown-content">...</div>}
    </div>
  );
}

usePrevious

Remember the previous value of something:

function usePrevious(value) {
  const ref = useRef();
  
  useEffect(() => {
    ref.current = value;
  }, [value]);
  
  return ref.current;
}

// Usage - show change direction
function StockPrice({ price }) {
  const previousPrice = usePrevious(price);
  
  let indicator = '';
  if (previousPrice !== undefined) {
    indicator = price > previousPrice ? '📈' : price < previousPrice ? '📉' : '';
  }
  
  return <div>{indicator} ${price}</div>;
}

The Pattern I Keep Using

After building dozens of custom hooks, I noticed a pattern. Most of my hooks follow this structure:

function useWhatever(input) {
  // 1. State to hold the result
  const [result, setResult] = useState(initialValue);
  
  // 2. Effect to compute/fetch/subscribe
  useEffect(() => {
    // Do the thing
    const value = computeSomething(input);
    setResult(value);
    
    // Cleanup if needed
    return () => cleanup();
  }, [input]);
  
  // 3. Return what the component needs
  return result;
}

State + Effect + Return. That’s the skeleton of most custom hooks.

Mistakes I Made Along the Way

Mistake 1: Forgetting Dependencies

// 🐛 Bug: URL changes won't trigger re-fetch
function useFetch(url) {
  const [data, setData] = useState(null);
  
  useEffect(() => {
    fetch(url).then(res => res.json()).then(setData);
  }, []);  // Missing url in dependencies!
  
  return data;
}

The linter catches this now, but I still sometimes forget.

Mistake 2: Returning Unstable Objects

// 🐛 Bug: Returns a new object every render
function useWindowSize() {
  const [width, setWidth] = useState(window.innerWidth);
  const [height, setHeight] = useState(window.innerHeight);
  
  // ...effect to update on resize...
  
  return { width, height };  // New object every time!
}

If someone uses this in a dependency array, they’ll get infinite loops. The fix is useMemo:

return useMemo(() => ({ width, height }), [width, height]);

Or just return the values separately:

return [width, height];

Mistake 3: Overcomplicating Things

Early on, I made hooks that were WAY too complex. A useForm hook that handled validation, submission, field registration, error states, touched states…

// 🤯 Too much
const { 
  values, errors, touched, isValid, isSubmitting,
  handleChange, handleBlur, handleSubmit, setFieldValue,
  setFieldError, resetForm, validateField
} = useForm({ initialValues, validationSchema, onSubmit });

Sometimes a simple useState is all you need. Not everything needs to be a custom hook.

When to Create a Custom Hook

My rule of thumb:

  1. You’re copying the same useState + useEffect combo between components → Extract it
  2. The logic is complex and cluttering your component → Extract it
  3. You want to test the logic separately from the UI → Extract it
  4. You want to share logic with your team or open-source it → Extract it

When NOT to create a custom hook:

  1. It’s only used in one place and probably won’t be reused
  2. It’s just wrapping a single useState with no additional logic
  3. You’re doing it for “cleanliness” but it makes the code harder to follow

The Ecosystem of Hooks

By 2020, the React ecosystem had exploded with hook libraries:

  • react-query / SWR - Data fetching with caching, revalidation, and more
  • react-hook-form - Form handling done right
  • react-use - Collection of essential hooks
  • framer-motion - Animation hooks
  • react-spring - Physics-based animations

Instead of building everything myself, I started reaching for these battle-tested libraries. Standing on the shoulders of giants.

What I Wish I’d Known Earlier

  1. Start simple. Your first custom hooks should be tiny. useToggle. useCounter. Build confidence before tackling complex ones.

  2. Look at existing hooks first. Chances are, someone’s already built what you need. And probably better.

  3. Custom hooks aren’t just for sharing. Even if only one component uses it, extracting complex logic into a hook can make your component cleaner.

  4. The hook doesn’t have to do everything. It’s okay to have multiple small hooks instead of one mega-hook.

  5. Test your hooks. The @testing-library/react-hooks package makes it easy.

The Journey Continues

Custom hooks were the gateway drug to better React architecture. Instead of thinking about components as “things that render stuff,” I started thinking about them as compositions of behavior.

But as my apps grew, a new problem emerged: passing data between deeply nested components. My prop chains were getting ridiculous.

<App user={user}>
  <Dashboard user={user}>
    <Sidebar user={user}>
      <UserAvatar user={user} />  {/* 4 levels deep just to show a picture! */}
    </Sidebar>
  </Dashboard>
</App>

Time to learn about Context.


P.S. — If you’re building your first custom hook, start with useToggle. It’s like 3 lines of code and incredibly satisfying:

function useToggle(initial = false) {
  const [value, setValue] = useState(initial);
  const toggle = useCallback(() => setValue(v => !v), []);
  return [value, toggle];
}

You’re welcome.

SS

Saurav Sitaula

Software Architect • Nepal

Back to all posts
Saurav.dev

© 2026 Saurav Sitaula.AstroNeoBrutalism