React Integration

Using Caller in your React and Next.js applications. Covers queries, mutations, cache invalidation, global config, and SSR patterns.

Caller provides a dedicated React client via the @igniter-js/caller/client subpath. This keeps the core library server-safe and avoids bundling React where it isn't needed.


Setup

Wrap your application with IgniterCallerProvider:

app/providers.tsx
import { IgniterCallerProvider } from '@igniter-js/caller/client';
import { api } from '@/lib/api';

export function Providers({ children }: { children: React.ReactNode }) {
  return (
    <IgniterCallerProvider callers={{ main: api }}>
      {children}
    </IgniterCallerProvider>
  );
}

Multiple API Clients

You can provide multiple named callers:

<IgniterCallerProvider callers={{
  main: publicApi,
  admin: adminApi,
  payments: stripeApi,
}}>
  {children}
</IgniterCallerProvider>

Creating a Typed Hook

Create a reusable typed hook for your app:

hooks/use-caller.ts
import { useIgniterCaller } from '@igniter-js/caller/client';
import type { api, adminApi } from '@/lib/api';

// Define your callers
type Callers = {
  main: typeof api;
  admin: typeof adminApi;
};

// Export a typed hook
export const useCaller = useIgniterCaller<Callers>();

Now every consumer gets full type safety:

function Dashboard() {
  const main = useCaller('main');
  //    ^? IgniterCallerClient<typeof api> — fully typed

  const { data } = main.get('/users').useQuery();
  //     ^? T | null — inferred from schema (if configured)
}

Queries (GET/HEAD)

Use .useQuery() for data fetching:

function UserProfile({ id }: { id: string }) {
  const main = useCaller('main');

  const {
    data,          // T | null — the fetched data
    error,         // IgniterError | null
    isLoading,     // true on initial load
    isFetching,    // true while fetching (including refetches)
    isSuccess,     // true when data was fetched successfully
    isError,       // true when the request failed
    status,        // 'loading' | 'success' | 'error'
    refetch,       // () => void — re-execute the query
    invalidate,    // (data?: T) => void — clear cache and refetch
    execute,       // (variables?) => Promise<ApiResponse<T>> — manual execution
  } = main.get('/users/:id')
    .params({ id })
    .useQuery({
      staleTime: 30_000,         // Cache for 30 seconds
      enabled: Boolean(id),       // Don't run if id is empty
      refetchOnMount: true,       // Refetch when component mounts (default: true)
      refetchOnWindowFocus: true, // Refetch on window focus (default: true)
      refetchInterval: 60_000,    // Auto-refetch every 60s
      refetchIntervalInBackground: false, // Don't refetch when tab is hidden
      initialData: null,          // Initial data before first fetch
    });

Query Options

OptionTypeDefaultDescription
staleTimenumberCache duration in ms
enabledbooleantrueSet to false to disable automatic fetching
paramsobjectURL path/query params
headersobjectPer-request headers
queryobjectQuery string parameters
refetchOnMountbooleantrueRefetch when component mounts
refetchOnWindowFocusbooleantrueRefetch on window focus
refetchIntervalnumberAuto-refetch interval in ms
refetchIntervalInBackgroundbooleantrueRefetch when tab is hidden
initialDataTInitial data before fetch
onSuccess(data: T) => voidCalled on successful fetch
onError(error: IgniterError) => voidCalled on failed fetch
onSettled(data, error) => voidCalled after every fetch
onRequest(response) => voidCalled before processing
onLoading(loading: boolean) => voidCalled when loading state changes

Query Lifecycle Callbacks

const { data } = main.get('/me').useQuery({
  staleTime: 60_000,

  onSuccess(data) {
    console.log('Profile loaded:', data?.name);
  },

  onError(error) {
    if (error.code === 'IGNITER_CALLER_HTTP_ERROR') {
      console.error('Failed to load profile');
    }
  },

  onSettled(data, error) {
    // Always called — success or failure
    analytics.track('profile_loaded', { success: !error });
  },

  onLoading(loading) {
    // Called when loading state changes
    setGlobalLoading(loading);
  },
});

Mutations (POST/PUT/PATCH/DELETE)

Mutations don't use .useMutate() directly on the builder — instead, use .execute() in event handlers, or use the .useMutate() helper provided by the client:

function CreateUserForm() {
  const main = useCaller('main');

  const { mutate, isLoading, error, data } = main.post('/users')
    .useMutate({
      onSuccess(data) {
        // Invalidate queries that depend on this data
        main.invalidate('/users');
        toast.success('User created!');
      },
      onError(error) {
        toast.error(`Failed: ${error.message}`);
      },
    });

  const handleSubmit = (values: CreateUserInput) => {
    mutate({ body: values });
  };

  return (
    <form onSubmit={(e) => { e.preventDefault(); /* ... */ }}>
      {isLoading && <Spinner />}
      <button type="submit" disabled={isLoading}>Create User</button>
    </form>
  );
}

useMutate Options

OptionTypeDescription
onSuccess(data: T) => voidCalled on successful mutation
onError(error: IgniterError) => voidCalled on failed mutation
onSettled(data, error) => voidCalled after every mutation
onRequest(response) => voidCalled before processing
onLoading(loading: boolean) => voidCalled when loading state changes

Manual Mutation Pattern

For cases where you need more control:

function DeleteUserButton({ userId }: { userId: string }) {
  const main = useCaller('main');
  const [isDeleting, setIsDeleting] = useState(false);

  const handleDelete = async () => {
    setIsDeleting(true);
    const { error } = await main.delete('/users/:id')
      .params({ id: userId })
      .execute();

    if (error) {
      toast.error('Failed to delete user');
    } else {
      main.invalidate('/users');
      toast.success('User deleted');
    }
    setIsDeleting(false);
  };

  return (
    <button onClick={handleDelete} disabled={isDeleting}>
      {isDeleting ? 'Deleting...' : 'Delete'}
    </button>
  );
}

Cache Invalidation

The client exposes an .invalidate() method for clearing cached queries:

function UpdateUserForm({ user }: { user: User }) {
  const main = useCaller('main');

  const handleSubmit = async (data: UpdateUserInput) => {
    const { error } = await main.patch('/users/:id')
      .params({ id: user.id })
      .body(data)
      .execute();

    if (!error) {
      // Invalidate specific queries
      main.invalidate(`/users/${user.id}`);    // Single user
      main.invalidate('/users');                // User list

      // Invalidate with data pre-population
      main.invalidate('/users', [{ ...user, ...data }]);

      // Invalidate multiple paths
      main.invalidate(['/users', '/dashboard']);
    }
  };
}

Per-Caller Configuration

Dynamically configure headers, cookies, and query parameters per caller:

function AuthenticatedApp() {
  const main = useCaller('main');

  useEffect(() => {
    // Set auth token on mount
    main.config.set('headers', {
      'Authorization': `Bearer ${token}`,
    });

    // Set default query params
    main.config.set('query', { version: '2' });

    return () => main.config.reset();
  }, [token]);
}

Config API

MethodDescription
config.set(key, value)Set a config value (headers, cookies, query)
config.get(key)Get a merged config value
config.reset()Reset all per-caller config

Global Configuration

Pass global defaults to all callers via the provider:

<IgniterCallerProvider
  callers={{ main: api, admin: adminApi }}
  hooks={{
    headers: { 'X-Client-Version': '2.0' },
    cookies: { theme: 'dark' },
    query: { lang: 'en' },
    onQuerySuccess(data) {
      console.log('Query succeeded:', data);
    },
    onQueryError(error) {
      console.error('Query failed:', error.message);
    },
    onMutationLoading(loading) {
      setGlobalLoading(loading);
    },
  }}
  onError={(error) => {
    // Global error handler for all callers
    if ((error as any).statusCode === 401) {
      redirectToLogin();
    }
  }}
>
  {children}
</IgniterCallerProvider>

Global Config Merging

Per-caller config merges with global config. Per-caller values take precedence:

Global headers:   { 'X-Version': '2.0', 'Accept': 'application/json' }
Caller headers:   { 'Authorization': 'Bearer ...' }
─────────────────────────────────────────────────────────
Merged headers:   { 'X-Version': '2.0', 'Accept': 'application/json', 'Authorization': 'Bearer ...' }

Raw Access

Need to escape the hook abstraction? Access the raw manager directly:

function handleBulkOperation() {
  const main = useCaller('main');

  // Direct manager access — bypasses hooks
  const { data } = await main.raw.post('/bulk')
    .body(massivePayload)
    .timeout(60_000)
    .execute();
}

Server Components (Next.js App Router)

In Server Components, use the standard Caller directly — no provider needed:

app/users/page.tsx
import { api } from '@/lib/api-server';

export default async function UsersPage() {
  const { data: users } = await api.get('/users')
    .params({ limit: 20 })
    .stale(60_000)
    .execute();

  return (
    <ul>
      {users?.map(user => (
        <li key={user.id}>{user.name}</li>
      ))}
    </ul>
  );
}

Client vs Server

  • Server Components: Use the standard api.get().execute() directly
  • Client Components: Use useCaller() with .useQuery() and .useMutate()
  • Import from @igniter-js/caller/client only in Client Components

Testing React Components

import { render, screen, waitFor } from '@testing-library/react';
import { IgniterCallerProvider } from '@igniter-js/caller/client';
import { IgniterCaller, IgniterCallerMock } from '@igniter-js/caller';

function renderWithCaller(ui: React.ReactElement) {
  const mock = IgniterCallerMock.create()
    .mock('/users', {
      GET: {
        response: [{ id: '1', name: 'Test User' }],
        status: 200,
      },
    })
    .build();

  const testApi = IgniterCaller.create()
    .withBaseUrl('https://test.api')
    .withMock({ enabled: true, mock })
    .build();

  return render(
    <IgniterCallerProvider callers={{ main: testApi }}>
      {ui}
    </IgniterCallerProvider>
  );
}

it('renders user list', async () => {
  renderWithCaller(<UserList />);
  await waitFor(() => {
    expect(screen.getByText('Test User')).toBeInTheDocument();
  });
});

Best Practices

React Integration Guidelines

  • Create a typed hookuseIgniterCaller<Callers>() for app-wide type safety
  • Set stale times — prevent unnecessary refetches on every render
  • Use enabled: false for conditional queries (e.g., waiting for a search term)
  • Invalidate related queries — after mutations, clear affected caches
  • Handle errors globally — use onError on the provider for common error handling
  • Use Server Components for data that can be fetched at request time

Next Steps