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 dataloading— request stateerrors— GraphQL or network errorsexecute— 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.