Skip to content
ExponaBLOG
REACT & NEXT.JS · DAY 010 / 100

Custom hooks that make code clean: a step-by-step guide

Extract logic, not markup.

Expona Admin11 min read

Custom hooks that make code clean

I've watched countless React codebases suffer from logic scattered across components, duplicated in three places, and impossible to test in isolation. The answer isn't better folder structure or stricter naming conventions—it's custom hooks that make code clean by extracting behavior, not markup. A well-designed custom hook becomes the single source of truth for a piece of business logic, lives independently of any component tree, and can be composed with other hooks to solve complex problems elegantly.

Why custom hooks matter in production code

In my experience building platforms at scale—whether it's a ride-hailing system handling real-time location updates or an e-commerce checkout managing payment states—I've learned that components should describe the UI, and hooks should describe the behavior. When you blur that line, you get monolithic components that are hard to reason about, impossible to reuse, and a nightmare to test.

Custom hooks let you extract logic into a reusable, composable unit that:

  • Lives independently of any specific component
  • Can be tested without rendering anything
  • Can be shared across multiple components or even multiple projects
  • Can depend on other custom hooks, creating layers of abstraction

The key insight is this: a hook is a reusable behavior, not a reusable template. It's about what the component does, not what it looks like.

Name them for what they do—useLeads, useDebounce, useMediaQuery

The first rule of custom hooks is the naming convention: always start with use. But more importantly, name it after the behavior it provides, not the component it's used in.

Bad hook names I've encountered in the wild:

  • useData – Too vague. Data about what?
  • useStuff – This isn't even trying.
  • useCardComponent – This couples the hook to a component.

Good hook names from real projects:

  • useLeads – Fetches and manages CRM leads
  • useDebounce – Debounces a value over time
  • useMediaQuery – Tracks if a media query matches
  • useOrderTicket – Manages the state and validation of an order ticket
  • usePrice – Calculates and caches price conversions

Here's a concrete example. I built a search interface for our travel platform that needed to debounce user input, fetch results, cache them, and handle errors. Instead of jamming all that into a SearchResults component, I extracted it:

tsx
// hooks/useSearchResults.ts
import { useState, useCallback, useEffect } from 'react';
import { debounce } from 'lodash';

export const useSearchResults = (query: string, delay = 300) => {
  const [results, setResults] = useState([]);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState(null);
  const cache = useRef(new Map());

  const fetchResults = useCallback(
    debounce(async (q: string) => {
      if (!q) {
        setResults([]);
        return;
      }

      if (cache.current.has(q)) {
        setResults(cache.current.get(q));
        return;
      }

      try {
        setLoading(true);
        const res = await fetch(`/api/search?q=${encodeURIComponent(q)}`);
        const data = await res.json();
        cache.current.set(q, data);
        setResults(data);
      } catch (err) {
        setError(err as Error);
      } finally {
        setLoading(false);
      }
    }, delay),
    [delay]
  );

  useEffect(() => {
    fetchResults(query);
  }, [query, fetchResults]);

  return { results, loading, error };
};

// Then in a component:
export const SearchPage = () => {
  const [query, setQuery] = useState('');
  const { results, loading, error } = useSearchResults(query);

  return (
    <>
      <input
        type="text"
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        placeholder="Search destinations..."
      />
      {loading && <p>Searching...</p>}
      {error && <p>Error: {error.message}</p>}
      <ul>
        {results.map((result) => (
          <li key={result.id}>{result.name}</li>
        ))}
      </ul>
    </>
  );
};

Notice the hook name useSearchResults immediately tells you what it does. The component is clean and focused on rendering. If another team needs search elsewhere, they use the hook, not copy the logic.

Return a clear API—An object for many values, a tuple for two

The shape of what a hook returns matters. It affects how ergonomic it is to use and how readable the calling code becomes.

For hooks that return two values (or one value and one setter), use a tuple pattern because it's so common that developers expect it. This is what React's useState does:

tsx
const [value, setValue] = useState(initialValue);

For hooks that return many values, return an object. Tuples become unreadable when you have three or more elements.

tsx
// Bad – a tuple with too many elements
const [leads, leadsLoading, leadsError, leadsPage, setLeadsPage, refetch] = useLeads();
// What's the order? You need to check the hook definition.

// Good – an object with clear keys
const { leads, loading, error, page, setPage, refetch } = useLeads();
// Each key is self-documenting.

Here's how I structure a hook that manages a paginated list:

tsx
// hooks/useLeads.ts
interface UseLeadsReturn {
  leads: Lead[];
  loading: boolean;
  error: Error | null;
  page: number;
  setPage: (page: number) => void;
  refetch: () => Promise<void>;
  hasNextPage: boolean;
}

export const useLeads = (limit = 20): UseLeadsReturn => {
  const [leads, setLeads] = useState<Lead[]>([]);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState<Error | null>(null);
  const [page, setPage] = useState(1);

  const refetch = useCallback(async () => {
    try {
      setLoading(true);
      const res = await fetch(
        `/api/leads?page=${page}&limit=${limit}`
      );
      const data = await res.json();
      setLeads(data.leads);
    } catch (err) {
      setError(err as Error);
    } finally {
      setLoading(false);
    }
  }, [page, limit]);

  useEffect(() => {
    refetch();
  }, [page, refetch]);

  return {
    leads,
    loading,
    error,
    page,
    setPage,
    refetch,
    hasNextPage: leads.length === limit,
  };
};

// Usage is crystal clear:
const CRMPage = () => {
  const { leads, loading, page, setPage } = useLeads();
  return (
    <>
      {loading ? <Spinner /> : <LeadsList leads={leads} />}
      <button onClick={() => setPage(page + 1)}>Next page</button>
    </>
  );
};

By returning a well-typed object, anyone using this hook knows exactly what's available without guessing or checking the implementation.

Compose hooks—useOrderTicket uses usePrice, useBalance and useValidation

This is where custom hooks get powerful. Hooks can depend on other hooks, creating layers of abstraction that model your business domain.

I worked on a ride-hailing platform where booking an order ticket involved several concerns: calculating the price (with surge, distance, and promotions), checking the user's balance, and validating that the ride was still available. Instead of a massive component or a monolithic hook, I composed smaller hooks:

tsx
// hooks/usePrice.ts
export const usePrice = (distance: number, isAm3: boolean) => {
  const [price, setPrice] = useState(0);
  const [loading, setLoading] = useState(false);

  useEffect(() => {
    setLoading(true);
    fetch(`/api/pricing?distance=${distance}&isAm3=${isAm3}`)
      .then((res) => res.json())
      .then((data) => setPrice(data.price))
      .finally(() => setLoading(false));
  }, [distance, isAm3]);

  return { price, loading };
};

// hooks/useBalance.ts
export const useBalance = (userId: string) => {
  const [balance, setBalance] = useState(0);

  useEffect(() => {
    fetch(`/api/users/${userId}/balance`)
      .then((res) => res.json())
      .then((data) => setBalance(data.balance));
  }, [userId]);

  return balance;
};

// hooks/useValidation.ts
export const useValidation = (
  distance: number,
  price: number,
  balance: number
) => {
  const canAfford = balance >= price;
  const isReasonableDistance = distance > 0 && distance < 100;
  const isValid = canAfford && isReasonableDistance;

  return { canAfford, isReasonableDistance, isValid };
};

// Now compose them into a high-level hook:
// hooks/useOrderTicket.ts
export const useOrderTicket = (userId: string, distance: number, isAm3: boolean) => {
  const { price, loading: priceLoading } = usePrice(distance, isAm3);
  const balance = useBalance(userId);
  const { isValid, canAfford } = useValidation(distance, price, balance);

  return { price, balance, isValid, canAfford, loading: priceLoading };
};

// And the component is simple:
const BookingPage = ({ userId, distance, isAm3 }: Props) => {
  const { price, balance, isValid, canAfford, loading } = useOrderTicket(
    userId,
    distance,
    isAm3
  );

  return (
    <>
      <h2>Order Summary</h2>
      <p>Price: ${price}</p>
      <p>Your balance: ${balance}</p>
      {!canAfford && <p style={{ color: 'red' }}>Insufficient balance</p>}
      <button disabled={!isValid || loading}>
        {loading ? 'Calculating...' : 'Confirm Booking'}
      </button>
    </>
  );
};

This composition approach gives you several wins:

  • Separation of concerns: Each hook owns one piece of logic.
  • Reusability: usePrice can be used in ten different components.
  • Testability: You can test each hook in isolation.
  • Readability: useOrderTicket reads like a description of the domain, not an implementation detail.

Keep them testable—Hooks without UI are easy to test with renderHook

One of the biggest advantages of extracting logic into custom hooks is that you can test the logic without mounting any components. React's @testing-library/react provides renderHook for exactly this purpose.

Here's how I test the useLeads hook:

tsx
// __tests__/useLeads.test.ts
import { renderHook, act, waitFor } from '@testing-library/react';
import { useLeads } from '../hooks/useLeads';

describe('useLeads', () => {
  beforeEach(() => {
    global.fetch = jest.fn();
  });

  it('should fetch leads on mount', async () => {
    (fetch as jest.Mock).mockResolvedValue({
      json: async () => ({
        leads: [
          { id: '1', name: 'Alice', status: 'active' },
          { id: '2', name: 'Bob', status: 'inactive' },
        ],
      }),
    });

    const { result } = renderHook(() => useLeads());

    expect(result.current.loading).toBe(true);

    await waitFor(() => {
      expect(result.current.loading).toBe(false);
    });

    expect(result.current.leads).toHaveLength(2);
    expect(result.current.leads[0].name).toBe('Alice');
  });

  it('should handle pagination', async () => {
    (fetch as jest.Mock).mockResolvedValue({
      json: async () => ({
        leads: [{ id: '1', name: 'Alice' }],
      }),
    });

    const { result, rerender } = renderHook(() => useLeads());

    await waitFor(() => expect(result.current.loading).toBe(false));

    act(() => {
      result.current.setPage(2);
    });

    await waitFor(() => {
      expect(fetch).toHaveBeenCalledWith('/api/leads?page=2&limit=20');
    });
  });

  it('should handle errors gracefully', async () => {
    (fetch as jest.Mock).mockRejectedValue(
      new Error('Network error')
    );

    const { result } = renderHook(() => useLeads());

    await waitFor(() => {
      expect(result.current.error).not.toBeNull();
      expect(result.current.error?.message).toBe('Network error');
    });
  });
});

Notice we're testing pure behavior: given this fetch response, does the hook return the right data? Given a page change, does it fetch the right URL? No components involved, no DOM rendering, no flaky async UI tests. Just logic.

This is why extracting logic into custom hooks pays dividends: your test suite becomes fast, reliable, and focused on what actually matters—the logic.

Common mistakes

  • Naming hooks after components instead of behaviors: useUserCard is tightly coupled to a card component. Better: useUser or useFetchUser.
  • Returning too many values in a tuple: If your hook returns more than two values, use an object. Tuple unpacking becomes illegible beyond that point.
  • Putting UI logic in a hook: Hooks are for behavior, components are for presentation. Don't put JSX in a hook. If you're tempted, you probably want a component instead.
  • Creating hooks that are too specific: A hook called useLeadDetailsPageLoader will never be reused. Create useLeads instead, and let different pages use it differently.
  • Forgetting to memoize callbacks with useCallback: If a hook returns a function and you don't memoize it, components that depend on that function will re-render unnecessarily. Always wrap returned functions with useCallback.
  • Not typing hook return values: Untyped hooks create confusion. Always define an interface for what the hook returns, especially in TypeScript.

Key takeaways

  • A hook is a reusable behavior, not a template. Name it after what it does, not which component uses it.
  • Return a clear API: Use tuples for two values, objects for many. Typing matters.
  • Compose hooks to model your domain. Small, focused hooks that combine into larger ones create elegant abstractions.
  • Test hooks in isolation with renderHook. This is where custom hooks shine—fast, reliable tests of pure logic.
  • Extract early and often. If you find yourself copy-pasting logic or struggling to test a component, that's a signal to extract a hook.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles