Create a custom hook to interact with GraphQL

Why this hook exists

At first glance, this hook looks redundant if you’re used to Apollo Client, Relay, or urql. Those libraries already handle caching, batching, retries, and much more.

However, this hook is intentionally minimal and was designed for a specific class of problems:

  • Legacy or non-SPA environments (e.g. MPA, Twig, server-rendered apps)
  • Situations where you cannot directly integrate a full GraphQL client
  • Authentication or networking constraints that require custom request logic
  • Teams that want full control over the fetch layer without global caches or providers

In my case, the hook was created while integrating React into an old MPA / Twig-based application, where:

  • There was no global app shell
  • Apollo’s client setup was overkill
  • Authentication rules prevented a straightforward GraphQL client connection

Instead of repeating fetch / axios logic across components, this hook centralizes GraphQL interaction in a reusable and predictable way.


What useGraph does on high level

useGraph is a thin abstraction over a GraphQL POST request.

It provides:

  • data — the GraphQL response data
  • loading — request state
  • errors — GraphQL or network errors
  • execute — a function to manually run the query

It behaves similarly to common data-fetching hooks, but without:

  • global state
  • caching layers
  • providers
  • hidden magic

Each hook instance is fully self-contained, making it easy to reason about and easy to drop into existing codebases.


Key design decisions

The hook validates its inputs immediately.
This avoids silent failures and makes misuse obvious during development.

if (!query) {
  throw new Error('useGraph: `query` is required');
}

Variables support

The hook supports GraphQL variables out of the box.

const { data, loading } = useGraph<UserQuery, { id: string }>({
  query: `
    query User($id: ID!) {
      user(id: $id) {
        id
        name
      }
    }
  `,
  graphServerURI: '/graphql',
  variables: { id: '1' },
});

Variables can also be overridden at execution time:

execute({ id: '2' });

This allows you to reuse the same hook instance with different inputs.


Lazy execution

By default, the query runs automatically when the component mounts. You can disable this behavior using the enabled option:

const { execute } = useGraph({
  query,
  graphServerURI,
  enabled: false,
});

This is useful for:

  • User-triggered actions
  • Forms
  • Modals
  • Dependent queries
<button onClick={() => execute()}>Load data</button>

Native fetch as a fallback

The hook uses the browser fetch API by default.

useGraph({
  query,
  graphServerURI,
});

This is useful when:

  • You don’t need special headers
  • Authentication is handled elsewhere (cookies, reverse proxy, etc.)
  • You want zero dependencies

The native fetch configuration is intentionally minimal and predictable.


Custom fetcher function

If you need full control over the networking layer, you can provide a custom fetcher function.

useGraph({
  query,
  graphServerURI,
  fetcherFn: customFetcher,
});

When fetcherFn is provided, it automatically overrides the native fetch implementation. No additional flags or configuration are required.

This allows you to:

  • Inject authentication logic
  • Use a different HTTP client (ky, axios, etc.)
  • Add retries, logging, or custom error handling
  • Adapt to legacy APIs or gateways

Example:

export const customFetcher = (serverUrl, query, variables, signal) => {
  return ky
    .post(serverUrl, {
      json: { query, variables },
      signal,
      throwHttpErrors: false,
    })
    .json();
};

The hook doesn’t care how the request is executed — only that it returns a GraphQL-shaped response.


Abort handling (important in real apps)

Each request is associated with an AbortController.

abortRef.current?.abort();
abortRef.current = new AbortController();

This prevents:

  • State updates after unmount
  • Race conditions when executing multiple requests quickly
  • Memory leaks in fast-changing UIs

This detail is easy to overlook, but crucial in real-world applications.


Automatic execution and manual re-execution

The query:

  • Runs automatically on mount (unless enabled: false)
  • Cleans up on unmount
  • Can be re-triggered manually via execute
const { data, loading, errors, execute } = useGraph(...);

This keeps the API small while covering the most common use cases.


Full implementation

Below is the complete implementation of the useGraph hook.

import { useState, useEffect, useCallback, useMemo, useRef } from 'react';

export interface GraphQLError {
  message: string;
  path?: (string | number)[];
  extensions?: Record<string, unknown>;
}

export interface GraphQLResponse<TData> {
  data?: TData;
  errors?: GraphQLError[];
}

export interface UseGraphOptions<TData, TVariables> {
  query: string;
  graphServerURI: string;
  variables?: TVariables;
  enabled?: boolean;
  fetcherFn?: (
    uri: string,
    query: string,
    variables?: TVariables,
    signal?: AbortSignal
  ) => Promise<GraphQLResponse<TData>>;
}

export interface UseGraphResult<TData, TVariables> {
  data: TData | null;
  loading: boolean;
  errors: GraphQLError[] | Error | null;
  execute: (variables?: TVariables) => Promise<void>;
}

export function useGraph<TData = unknown, TVariables = Record<string, unknown>>(
  options: UseGraphOptions<TData, TVariables>
): UseGraphResult<TData, TVariables> {
  const {
    query,
    graphServerURI,
    variables,
    enabled = true,
    fetcherFn,
  } = options;

  if (!query) {
    throw new Error('useGraph: `query` is required');
  }

  if (!graphServerURI) {
    throw new Error('useGraph: `graphServerURI` is required');
  }

  const [data, setData] = useState<TData | null>(null);
  const [errors, setErrors] = useState<GraphQLError[] | Error | null>(null);
  const [loading, setLoading] = useState(false);

  const abortRef = useRef<AbortController | null>(null);

  const nativeFetchOptions = useMemo<RequestInit>(() => {
    return {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
      },
    };
  }, []);

  const execute = useCallback(
    async (overrideVariables?: TVariables) => {
      setLoading(true);
      setErrors(null);

      abortRef.current?.abort();
      abortRef.current = new AbortController();

      try {
        const finalVariables = overrideVariables ?? variables;
        let result: GraphQLResponse<TData>;

        if (fetcherFn) {
          result = await fetcherFn(
            graphServerURI,
            query,
            finalVariables,
            abortRef.current.signal
          );
        } else {
          const response = await fetch(graphServerURI, {
            ...nativeFetchOptions,
            body: JSON.stringify({
              query,
              variables: finalVariables,
            }),
            signal: abortRef.current.signal,
          });

          if (!response.ok) {
            throw new Error(`Network error: ${response.status}`);
          }

          result = (await response.json()) as GraphQLResponse<TData>;
        }

        if (result.errors?.length) {
          setErrors(result.errors);
          setData(null);
          return;
        }

        setData(result.data ?? null);
      } catch (err) {
        if ((err as Error).name !== 'AbortError') {
          setErrors(err as Error);
          setData(null);
        }
      } finally {
        setLoading(false);
      }
    },
    [fetcherFn, graphServerURI, nativeFetchOptions, query, variables]
  );

  useEffect(() => {
    if (!enabled) return;

    execute();

    return () => {
      abortRef.current?.abort();
    };
  }, [enabled, execute]);

  return {
    data,
    loading,
    errors,
    execute,
  };
}

Full example

Below is a complete, real-world example showing how useGraph can be used inside a React component to fetch and re-fetch user data.

This example demonstrates:

  • Type-safe data and variables
  • Automatic execution when a required prop is available
  • Manual re-execution via execute
  • Basic loading and error handling

import { useGraph } from './hooks/useGraph';

// define you GQL query
const GET_USER_QUERY = `
  query GetUser($id: ID!) {
    user(id: $id) {
      id
      name
      email
    }
  }
`;

// type definintions
interface UserData {
  user: {
    id: string;
    name: string;
    email: string;
  };
}

interface UserVariables {
  id: string;
}

// use in your React component
const UserProfile = ({ userId }: { userId: string }) => {
  const { data, loading, errors, execute } = useGraph<
    UserData,
    UserVariables
  >({
    query: GET_USER_QUERY,
    graphServerURI: '/api/graphql',
    variables: { id: userId },
    enabled: !!userId,
  });

  if (loading) {
    return <p>Loading...</p>;
  }

  if (errors) {
    const message = Array.isArray(errors)
      ? errors.map(e => e.message).join(', ')
      : errors.message;

    return <p>Error: {message}</p>;
  }

  if (!data) {
    return <p>No user data</p>;
  }

  return (
    <div>
      <h1>{data.user.name}</h1>
      <p>{data.user.email}</p>

      <button onClick={() => execute()}>
        Refresh data
      </button>
    </div>
  );
};


When not to use this hook

This hook is not a replacement for Apollo Client or similar libraries when you need:

  • Normalized caching
  • Automatic query deduplication
  • Advanced pagination helpers
  • DevTools integration
  • Complex client-side GraphQL state

For large, data-heavy SPAs, a full GraphQL client is still the right choice.


When this hook makes sense

useGraph works well when:

  • You are incrementally introducing React into a legacy application
  • You want a lightweight, dependency-free solution
  • You need full control over the HTTP layer
  • You don’t want a global provider or client
  • Your GraphQL usage is simple and explicit

Final thoughts

useGraph is not about competing with Apollo.

It is about:

  • Explicit data flow
  • Minimal abstractions
  • Predictable behavior
  • Pragmatic integration in constrained environments

In those scenarios, a small, well-defined hook can be more valuable than a full-featured GraphQL client.