useFetch

A simple reusable GET-only fetch hook for React.

The Hook

import { useEffect, useState } from "react";
 
interface FetchState<T> {
  data: T | null;
  loading: boolean;
  error: string | null;
}
 
/**
 * useFetch - A simple GET-only fetch hook for React.
 *
 * @param url - The URL to fetch data from
 * @param options - Optional fetch options
 * @param deps - Dependencies array to refetch when changed
 *
 * Limitations:
 * 1. GET requests only.
 * 2. Does not support POST, PUT, DELETE, or other HTTP methods.
 * 3. Does not cache responses (always fetches fresh data).
 */
export function useFetch<T = unknown>(
  url: string,
  options?: RequestInit,
  deps: unknown[] = [],
): FetchState<T> {
  const [data, setData] = useState<T | null>(null);
  const [loading, setLoading] = useState<boolean>(true);
  const [error, setError] = useState<string | null>(null);
 
  useEffect(() => {
    if (!url) return;
 
    const controller = new AbortController();
    const signal = controller.signal;
    let isCancelled = false;
 
    setLoading(true);
    setError(null);
 
    fetch(url, { ...options, signal })
      .then(async (res) => {
        if (!res.ok) {
          throw new Error(`HTTP error! Status: ${res.status}`);
        }
        const json = (await res.json()) as T;
        if (!isCancelled) setData(json);
      })
      .catch((err) => {
        if (!isCancelled && err.name !== "AbortError") {
          setError(err.message || "Something went wrong");
        }
      })
      .finally(() => {
        if (!isCancelled) setLoading(false);
      });
 
    return () => {
      isCancelled = true;
      controller.abort();
    };
  }, [url, ...deps]);
 
  return { data, loading, error };
}

Example 1

"use client";
 
import React from "react";
import { useFetch } from "@/hooks/useFetch";
 
interface Post {
  id: number;
  title: string;
  body: string;
}
 
export default function PostsList() {
  const { data: posts, loading, error } = useFetch<Post[]>(
    "https://jsonplaceholder.typicode.com/posts"
  );
 
  if (loading) return <p>Loading posts...</p>;
  if (error) return <p>Error: {error}</p>;
 
  return (
    <div>
      <h3>Posts from JSONPlaceholder</h3>
      <ul>
        {posts?.slice(0, 5).map((post) => (
          <li key={post.id}>
            <b>{post.title}</b>
            <p>{post.body}</p>
          </li>
        ))}
      </ul>
    </div>
  );
}

Example 2

"use client";
 
import React, { useEffect, useState } from "react";
import { useFetch } from "@/hooks/useFetch";
 
interface Story {
  id: number;
  title: string;
  url: string;
  by: string;
  score: number;
}
 
export default function HackerNewsTop() {
  const { data: topIds, loading: idsLoading, error: idsError } = useFetch<number[]>(
    "https://hacker-news.firebaseio.com/v0/topstories.json"
  );
 
  const [stories, setStories] = useState<Story[]>([]);
  const [loading, setLoading] = useState(true);
 
  useEffect(() => {
    if (!topIds) return;
 
    const fetchStories = async () => {
      setLoading(true);
      try {
        const top5Ids = topIds.slice(0, 5);
        const promises = top5Ids.map((id) =>
          fetch(`https://hacker-news.firebaseio.com/v0/item/${id}.json`).then((res) => res.json())
        );
        const results: Story[] = await Promise.all(promises);
        setStories(results);
      } catch {
        setStories([]);
      } finally {
        setLoading(false);
      }
    };
 
    fetchStories();
  }, [topIds]);
 
  if (idsLoading || loading) return <p>Loading top Hacker News stories...</p>;
  if (idsError) return <p>Error: {idsError}</p>;
 
  return (
    <div>
      <h2>Top 5 Hacker News Stories</h2>
      <ul>
        {stories.map((story) => (
          <li key={story.id}>
            <a href={story.url} target="_blank" rel="noopener noreferrer">
              {story.title}
            </a>{" "}
            by <b>{story.by}</b> (Score: {story.score})
          </li>
        ))}
      </ul>
    </div>
  );
}