Client
Learn how to consume your Igniter.js API from the client-side using type-safe hooks, queries, mutations, and real-time subscriptions in React applications.
Overview
The Igniter.js Client provides a fully type-safe way to consume your API from React applications. It automatically generates typed hooks (useQuery, useMutation, useRealtime) based on your router definition, giving you end-to-end type safety from server to client.
// server/igniter.ts
export const igniter = createIgniterBuilder()
.context<{ db: Database }>()
.create();
export const router = igniter.router({
users: userController
});
export type AppRouter = typeof router;// client/app.tsx
import { createIgniterClient } from '@igniter-js/core/client';
import type { AppRouter } from '../server/igniter';
const client = createIgniterClient<AppRouter>({
baseURL: 'http://localhost:3000',
basePATH: '/api/v1',
router: () => router // ✅ Type-safe client!
});
function UserList() {
const { data, isLoading } = client.users.list.useQuery();
// ^? { users: User[] } - Fully typed!
if (isLoading) return <div>Loading...</div>;
return (
<ul>
{data.users.map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
);
}Zero Configuration
No code generation, no manual types. The client automatically infers all types from your router!
Client Setup
Install Dependencies
npm install @igniter-js/core reactpnpm add @igniter-js/core reactyarn add @igniter-js/core reactbun add @igniter-js/core reactCreate the Client
// lib/client.ts
import { createIgniterClient } from '@igniter-js/core/client';
import type { AppRouter } from '../server/igniter';
export const client = createIgniterClient<AppRouter>({
baseURL: process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000',
basePATH: '/api/v1',
router: () => import('../server/igniter').then(m => m.router)
});Wrap Your App with Provider
// app/layout.tsx
import { IgniterProvider } from '@igniter-js/core/client';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html>
<body>
<IgniterProvider>
{children}
</IgniterProvider>
</body>
</html>
);
}useQuery Hook
Use useQuery for fetching data (GET requests):
Basic Query
function UserProfile({ userId }: { userId: string }) {
const { data, isLoading, error } = client.users.getById.useQuery({
params: { id: userId }
});
if (isLoading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
return (
<div>
<h1>{data.user.name}</h1>
<p>{data.user.email}</p>
</div>
);
}Query with Parameters
function ProductSearch() {
const [searchTerm, setSearchTerm] = useState('');
const { data, isLoading } = client.products.search.useQuery({
query: {
q: searchTerm,
limit: 20
}
});
return (
<div>
<input
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="Search products..."
/>
{isLoading ? (
<div>Searching...</div>
) : (
<ul>
{data.products.map(product => (
<li key={product.id}>{product.name}</li>
))}
</ul>
)}
</div>
);
}useQuery Options
const { data, isLoading, error, refetch, invalidate } = client.users.list.useQuery({
// Query parameters
query: { status: 'active' },
params: { id: '123' },
// Initial data (SSR)
initialData: { users: [] },
// Caching
staleTime: 5000, // Consider data fresh for 5 seconds
// Auto-refetch behaviors
enabled: true, // Enable/disable query
refetchOnMount: true, // Refetch on component mount
refetchOnWindowFocus: true, // Refetch when window regains focus
refetchOnReconnect: true, // Refetch when internet reconnects
refetchInterval: 10000, // Poll every 10 seconds
refetchIntervalInBackground: false, // Don't poll when tab is hidden
// Lifecycle callbacks
onLoading: (isLoading) => console.log('Loading:', isLoading),
onSuccess: (data) => console.log('Success:', data),
onError: (error) => console.error('Error:', error),
onRequest: (response) => console.log('Request complete:', response),
onSettled: (data, error) => console.log('Settled:', { data, error })
});useQuery Return Values
{
// Data
data: TData | null, // Response data
error: TError | null, // Error object
variables: TInput | undefined, // Last used query parameters
// Status booleans
isLoading: boolean, // Initial load
isFetching: boolean, // Any fetch (including refetch)
isSuccess: boolean, // Successful response
isError: boolean, // Error occurred
status: 'loading' | 'success' | 'error',
// Actions
refetch: (invalidateCache?: boolean) => void, // Refetch with same params
execute: (params?: TInput) => Promise<TData>, // Fetch with new params
invalidate: () => void // Invalidate cache
}useMutation Hook
Use useMutation for modifying data (POST, PUT, PATCH, DELETE):
Basic Mutation
function CreateUserForm() {
const { mutate, isLoading, error } = client.users.create.useMutation();
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
await mutate({
body: {
name: formData.get('name') as string,
email: formData.get('email') as string
}
});
};
return (
<form onSubmit={handleSubmit}>
<input name="name" required />
<input name="email" type="email" required />
<button type="submit" disabled={isLoading}>
{isLoading ? 'Creating...' : 'Create User'}
</button>
{error && <p>Error: {error.message}</p>}
</form>
);
}Mutation with Callbacks
function DeletePostButton({ postId }: { postId: string }) {
const { mutate, isLoading } = client.posts.delete.useMutation({
onSuccess: (data) => {
console.log('Post deleted:', data);
// Invalidate queries
queryClient.invalidate(['posts']);
},
onError: (error) => {
console.error('Failed to delete:', error);
alert('Failed to delete post');
}
});
return (
<button
onClick={() => mutate({ params: { id: postId } })}
disabled={isLoading}
>
{isLoading ? 'Deleting...' : 'Delete'}
</button>
);
}useMutation Options
const { mutate, data, error, isLoading, retry } = client.users.update.useMutation({
// Static parameters (applied to all mutations)
params: { id: userId },
// Lifecycle callbacks
onLoading: (isLoading) => console.log('Loading:', isLoading),
onSuccess: (data) => console.log('Success:', data),
onError: (error) => console.error('Error:', error),
onRequest: (response) => console.log('Request complete:', response),
onSettled: (data, error) => console.log('Settled:', { data, error })
});
// Call mutation
mutate({
body: { name: 'New Name' },
// These override static params
params: { id: '456' }
});useMutation Return Values
{
// Data
data: TData | null, // Response data
error: TError | null, // Error object
variables: TInput | undefined, // Last used mutation parameters
// Status booleans
isLoading: boolean,
isSuccess: boolean,
isError: boolean,
status: 'idle' | 'loading' | 'success' | 'error',
// Actions
mutate: (params: TInput) => Promise<TData>, // Execute mutation
retry: () => void // Retry last mutation
}Real-Time with useRealtime
Subscribe to Server-Sent Events (SSE) streams:
Basic Real-Time Subscription
function LiveNotifications({ userId }: { userId: string }) {
const { data, isConnected } = client.notifications.live.useRealtime({
channelId: `notifications:user:${userId}`,
onMessage: (notification) => {
console.log('New notification:', notification);
// Show toast, play sound, etc.
},
onConnect: () => {
console.log('Connected to notifications stream');
},
onDisconnect: () => {
console.log('Disconnected from notifications stream');
}
});
return (
<div>
<div>Status: {isConnected ? '🟢 Connected' : '🔴 Disconnected'}</div>
{data && (
<div className="notification">
<strong>{data.title}</strong>
<p>{data.message}</p>
</div>
)}
</div>
);
}Real-Time with Initial Data
function LiveMetrics() {
const { data, isConnected } = client.metrics.stream.useRealtime({
channelId: 'metrics:dashboard',
initialData: {
cpu: 0,
memory: 0,
requests: 0
},
onMessage: (metrics) => {
// Update chart, gauge, etc.
}
});
return (
<div>
<h2>Live Server Metrics</h2>
<div>CPU: {data?.cpu}%</div>
<div>Memory: {data?.memory}%</div>
<div>Requests/s: {data?.requests}</div>
</div>
);
}Direct Fetching (No Hooks)
Use .query() or .mutate() for non-hook scenarios:
Direct Query
// Outside React component
async function fetchUser(id: string) {
const response = await client.users.getById.query({
params: { id }
});
if (response.error) {
throw new Error(response.error.message);
}
return response.data;
}Direct Mutation
// In event handler, middleware, etc.
async function createUser(userData: CreateUserInput) {
const response = await client.users.create.mutate({
body: userData
});
return response.data;
}Cache Management
Using IgniterQueryClient
import { useIgniterQueryClient } from '@igniter-js/core/client';
function Dashboard() {
const queryClient = useIgniterQueryClient();
const handleRefresh = () => {
// Invalidate all 'users' queries
queryClient.invalidate(['users.*']);
// Invalidate specific query
queryClient.invalidate(['users.list']);
// Invalidate multiple
queryClient.invalidate(['users.list', 'posts.list']);
};
return <button onClick={handleRefresh}>Refresh Data</button>;
}Automatic Cache Invalidation
When using response.revalidate() on the server, connected clients are automatically notified:
// Server-side
const createPost = igniter.mutation({
name: 'Create Post',
description: 'Create a new post',
path: '/',
method: 'POST',
handler: async ({ response }) => {
const post = await db.posts.create({ data: {...} });
// ✅ Automatically invalidates 'posts' query on all clients
return response
.revalidate(['posts', 'dashboard'])
.created({ post });
}
});Error Handling
Handle Errors in useQuery
function UserProfile({ userId }: { userId: string }) {
const { data, error, isError, isLoading } = client.users.getById.useQuery({
params: { id: userId },
onError: (error) => {
// Log to error tracking service
console.error('Failed to load user:', error);
}
});
if (isLoading) return <Skeleton />;
if (isError) {
return (
<div className="error">
<h2>Failed to load user</h2>
<p>{error.message}</p>
{error.code === 'ERR_NOT_FOUND' && (
<p>User not found</p>
)}
</div>
);
}
return <div>{data.user.name}</div>;
}Handle Errors in useMutation
function CreatePostForm() {
const { mutate, error, isError } = client.posts.create.useMutation({
onError: (error) => {
if (error.code === 'VALIDATION_ERROR') {
// Show validation errors
console.log(error.details);
} else {
// Generic error
alert('Failed to create post');
}
}
});
return (
<form onSubmit={(e) => {
e.preventDefault();
mutate({ body: { title: '...', content: '...' } });
}}>
{/* Form fields */}
{isError && (
<div className="error">{error.message}</div>
)}
<button type="submit">Create</button>
</form>
);
}Server-Side Rendering (SSR)
Next.js App Router
// app/users/page.tsx
import { client } from '@/lib/client';
export default async function UsersPage() {
// ✅ Fetch on server
const { data } = await client.users.list.query();
return (
<div>
<h1>Users</h1>
<UserList initialData={data} />
</div>
);
}
// components/UserList.tsx
'use client';
function UserList({ initialData }) {
const { data } = client.users.list.useQuery({
initialData // ✅ Hydrate with server data
});
return (
<ul>
{data.users.map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
);
}Next.js Pages Router
// pages/users.tsx
export async function getServerSideProps() {
const { data } = await client.users.list.query();
return {
props: { users: data.users }
};
}
export default function UsersPage({ users }) {
const { data } = client.users.list.useQuery({
initialData: { users }
});
return <ul>{/* ... */}</ul>;
}Best Practices
1. Create a Shared Client Instance
// ✅ Good - Single instance
// lib/client.ts
export const client = createIgniterClient<AppRouter>({ ... });
// ❌ Bad - Multiple instances
function Component() {
const client = createIgniterClient<AppRouter>({ ... });
}2. Use initialData for SSR
// ✅ Good - Hydrate with server data
const { data } = client.users.list.useQuery({
initialData: serverData
});
// ❌ Bad - Redundant fetch on client
const { data } = client.users.list.useQuery();3. Invalidate After Mutations
// ✅ Good - Auto-refresh related queries
const { mutate } = client.users.create.useMutation({
onSuccess: () => {
queryClient.invalidate(['users.list']);
}
});
// ❌ Bad - Stale data
const { mutate } = client.users.create.useMutation();4. Handle Loading and Error States
// ✅ Good - Complete UX
if (isLoading) return <Skeleton />;
if (isError) return <ErrorMessage error={error} />;
return <Content data={data} />;
// ❌ Bad - No feedback
return <Content data={data} />;Client Maintenance
Schema Synchronization
Critical: You MUST run schema generation BEFORE using the client. The client depends entirely on the generated schema to function.
The client types are automatically derived from your server-side controllers and actions. Without the generated schema, the client will not work.
When your API changes (new controllers, actions, or modified signatures), update the client:
npx @igniter-js/cli@latest generate schemapnpm dlx @igniter-js/cli@latest generate schemayarn dlx @igniter-js/cli@latest generate schemabunx @igniter-js/cli@latest generate schemaThis regenerates:
src/igniter.schema.ts- Updated type definitions for full IntelliSense- Client method signatures that stay in sync with your API