Skip to main content
Version: v0.17.0

Service Function React Hooks

Magellan provides two React hooks for calling service functions: useQueryService for reading data and useMutationService for modifying data. These hooks are built on TanStack Query v5 and provide automatic caching, error handling, and type-safe state management.

Table of Contents


Quick Start

Reading Data with useQueryService

The simplest way to fetch data from a service function:

import React, { FC } from "react";
import { useQueryService } from "@quatico/magellan-react";
import { getUserById } from "./services";

const UserProfile: FC = () => {
const { data, isPending, isError, error } = useQueryService({
serviceFn: getUserById,
serviceFnArgs: { id: 1 },
});

if (isPending) return <div>Loading...</div>;
if (isError) return <div>Error: {error.message}</div>;

return <div>Hello, {data.name}!</div>;
};

That's it! The hook automatically:

  • Calls your service function
  • Caches the result
  • Handles loading and error states
  • Provides type-safe access to data

Writing Data with useMutationService

The simplest way to modify data:

import React, { FC } from "react";
import { useMutationService } from "@quatico/magellan-react";
import { createUser } from "./services";

const CreateUserButton: FC = () => {
const { mutate, isPending, isError, error } = useMutationService({
serviceFn: createUser,
});

const handleClick = () => {
mutate({ name: "John Doe", email: "john@example.com" });
};

return (
<div>
<button onClick={handleClick} disabled={isPending}>
{isPending ? "Creating..." : "Create User"}
</button>
{isError && <div>Error: {error.message}</div>}
</div>
);
};

That's it! The hook automatically:

  • Triggers your service function on demand
  • Manages pending/error/success states
  • Provides type-safe mutation results

Understanding the Basics

When to Use Which Hook

Use useQueryService for reading data:

  • Fetching user profiles, lists, or any GET-like operations
  • Data that should be cached and automatically refetched
  • Operations that are safe to call multiple times

Use useMutationService for writing data:

  • Creating, updating, or deleting records (POST, PUT, DELETE-like operations)
  • Operations that change server state
  • Actions triggered by user interactions (button clicks, form submissions)

How the Hooks Work

Both hooks are thin wrappers around TanStack Query's useQuery and useMutation hooks. They:

  1. Call your service functions - No need to write fetch logic or handle HTTP
  2. Serialize/deserialize data - Automatically handled by Magellan's transport layer
  3. Manage cache - Intelligent caching based on query keys
  4. Provide discriminated unions - TypeScript knows exactly what data is available based on state
  5. Handle errors gracefully - Converts all errors to Error instances with useful messages

Type-Safe State with Discriminated Unions

The hooks return discriminated unions that enable powerful type narrowing:

const queryResult = useQueryService({ serviceFn: getUser });

// TypeScript doesn't know if data exists yet
console.log(queryResult.data); // Type: User | undefined

// After checking isSuccess, TypeScript knows data exists
if (queryResult.isSuccess) {
console.log(queryResult.data); // Type: User (no undefined!)
console.log(queryResult.error); // Type: null
}

// After checking isError, TypeScript knows error exists
if (queryResult.isError) {
console.log(queryResult.error); // Type: Error (not null!)
console.log(queryResult.data); // Type: undefined
}

This eliminates the need for optional chaining (?.) and makes your code safer.


Essential State Management

Handling Loading States

useQueryService provides two loading-related flags:

const { isPending, isLoading } = useQueryService({
serviceFn: fetchUser,
});

// isPending: true when query hasn't completed initial fetch
// isLoading: true only during the first fetch (subset of isPending)

Most of the time, use isPending:

if (isPending) {
return <LoadingSpinner />;
}

useMutationService has simpler loading:

const { isPending, isIdle } = useMutationService({
serviceFn: createUser,
});

// isIdle: true before mutation is called
// isPending: true while mutation is running

Handling Errors

Both hooks provide isError and error:

const queryResult = useQueryService({ serviceFn: fetchUser });

if (queryResult.isError) {
// TypeScript knows error is defined here
return <ErrorMessage>{queryResult.error.message}</ErrorMessage>;
}

For mutations:

const mutationResult = useMutationService({ serviceFn: createUser });

if (mutationResult.isError) {
return <Alert severity="error">{mutationResult.error.message}</Alert>;
}

Handling Success

Use isSuccess to safely access data:

const queryResult = useQueryService({ serviceFn: fetchUser });

if (queryResult.isSuccess) {
// TypeScript knows data is defined here
return <UserCard user={queryResult.data} />;
}

For mutations, check success to show confirmation:

const mutationResult = useMutationService({ serviceFn: createUser });

if (mutationResult.isSuccess) {
return <SuccessMessage>User created: {mutationResult.data.name}</SuccessMessage>;
}

Type Narrowing in Practice

Handle all states explicitly for the best user experience:

const UserProfile: FC = () => {
const queryResult = useQueryService({
serviceFn: getUserById,
serviceFnArgs: { id: 1 },
});

// Handle each state explicitly
if (queryResult.isPending) {
return <LoadingSpinner />;
}

if (queryResult.isError) {
return <ErrorPage error={queryResult.error} />;
}

if (queryResult.isSuccess) {
return <UserDetails user={queryResult.data} />;
}

// This is unreachable but TypeScript requires it
return null;
};

Conditional Queries

Using the enabled Option

Sometimes you don't want a query to run immediately. Use the enabled option:

const { data, isPending } = useQueryService({
serviceFn: fetchUserProfile,
serviceFnArgs: { id: userId },
options: {
enabled: !!userId, // Only run when userId exists
},
});

Why this matters: Prevents unnecessary API calls and errors when dependencies aren't ready.

Common Scenarios

1. Authentication Gates

Don't fetch user data until logged in:

const Dashboard: FC = () => {
const { isAuthenticated, user } = useAuth();

const { data: preferences } = useQueryService({
serviceFn: getUserPreferences,
serviceFnArgs: { userId: user?.id },
options: {
enabled: isAuthenticated && !!user?.id,
},
});

if (!isAuthenticated) {
return <LoginPrompt />;
}

// preferences will only be fetched after authentication
return <UserDashboard preferences={preferences} />;
};

2. Dependent Queries

Fetch data that depends on other data:

const OrderDetails: FC = () => {
// First, fetch the order
const orderQuery = useQueryService({
serviceFn: getOrder,
serviceFnArgs: { orderId: 123 },
});

// Then, fetch the customer (only after order loads)
const customerQuery = useQueryService({
serviceFn: getCustomer,
serviceFnArgs: { customerId: orderQuery.data?.customerId },
options: {
enabled: orderQuery.isSuccess && !!orderQuery.data?.customerId,
},
});

if (orderQuery.isPending) return <Loading />;
if (orderQuery.isError) return <Error />;

return (
<div>
<OrderInfo order={orderQuery.data} />
{customerQuery.isSuccess && (
<CustomerInfo customer={customerQuery.data} />
)}
</div>
);
};

3. Manual Refetch

Start disabled, trigger manually:

const SearchUsers: FC = () => {
const [searchTerm, setSearchTerm] = useState("");

const { data, refetch, isPending } = useQueryService({
serviceFn: searchUsers,
serviceFnArgs: { query: searchTerm },
options: {
enabled: false, // Don't auto-run
},
});

const handleSearch = () => {
refetch(); // Manually trigger the query
};

return (
<div>
<input value={searchTerm} onChange={e => setSearchTerm(e.target.value)} />
<button onClick={handleSearch} disabled={isPending}>
Search
</button>
{data && <SearchResults results={data} />}
</div>
);
};

Checking if a Query is Enabled

Use the isEnabled flag to check the query's enabled state:

const { data, isEnabled, isPending } = useQueryService({
serviceFn: fetchUser,
options: { enabled: userIsLoggedIn },
});

if (!isEnabled) {
return <PleaseLogin />; // Query is disabled
}

if (isPending) {
return <Loading />;
}

return <UserProfile data={data} />;

Advanced Error Handling

Loading Errors vs Refetch Errors

useQueryService distinguishes between two types of errors:

  1. Loading Error: The initial fetch failed (no data available)
  2. Refetch Error: A background refetch failed (stale data is still available)
const { data, isError, isLoadingError, isRefetchError } = useQueryService({
serviceFn: fetchUser,
});

if (isError) {
if (isLoadingError) {
// No data available at all
return <ErrorState message="Failed to load user" />;
}

if (isRefetchError) {
// We have stale data! Show it with a warning
return (
<div>
<WarningBanner>Failed to refresh. Showing cached data.</WarningBanner>
<UserProfile user={data} /> {/* data is available! */}
</div>
);
}
}

Why this matters: Your users get a much better experience when you show stale data with a warning rather than an error page. The data might be a few minutes old, but it's still useful.

Showing Stale Data During Errors

This pattern provides a graceful degradation:

const ProductList: FC = () => {
const { data, isError, isLoadingError, isRefetchError } = useQueryService({
serviceFn: fetchProducts,
});

// Complete failure - show error page
if (isLoadingError) {
return (
<ErrorPage>
<h2>Failed to load products</h2>
<button onClick={() => window.location.reload()}>
Try Again
</button>
</ErrorPage>
);
}

// Partial failure - show stale data with warning
return (
<div>
{isRefetchError && (
<Banner severity="warning">
Unable to get latest updates. Showing cached data.
</Banner>
)}
{data && <ProductGrid products={data} />}
</div>
);
};

Custom Error Types

By default, errors are typed as Error. You can specify custom error types:

type ApiError = Error & {
statusCode: number;
details: string[];
};

const { error, isError } = useQueryService<typeof fetchUser, ApiError>({
serviceFn: fetchUser,
});

if (isError) {
// error is typed as ApiError
if (error.statusCode === 404) {
return <NotFound />;
}
if (error.statusCode === 403) {
return <Forbidden />;
}
return <GenericError details={error.details} />;
}

Note: Your service functions must throw errors of the specified type, or the Magellan error handler will wrap them in a standard Error.

Error Recovery Patterns

1. Retry Button

const DataDisplay: FC = () => {
const { data, isError, error, refetch, isPending } = useQueryService({
serviceFn: fetchData,
});

if (isError) {
return (
<ErrorPanel>
<p>{error.message}</p>
<button onClick={() => refetch()} disabled={isPending}>
{isPending ? "Retrying..." : "Try Again"}
</button>
</ErrorPanel>
);
}

return <DataTable data={data} />;
};

2. Fallback Data

type UserAvatarProps = {
userId: string;
};

const UserAvatar: FC<UserAvatarProps> = ({ userId }) => {
const { data, isError } = useQueryService({
serviceFn: getUserAvatar,
serviceFnArgs: { userId },
});

// Show default avatar on error
const avatarUrl = isError ? "/default-avatar.png" : data.url;

return <img src={avatarUrl} alt="User avatar" />;
};

3. Error Boundaries

Wrap components in error boundaries for unexpected errors:

import { ErrorBoundary } from "react-error-boundary";

const App: FC = () => {
return (
<ErrorBoundary FallbackComponent={ErrorFallback}>
<UserDashboard />
</ErrorBoundary>
);
};

type ErrorFallbackProps = {
error: Error;
};

const ErrorFallback: FC<ErrorFallbackProps> = ({ error }) => {
return (
<div>
<h2>Something went wrong</h2>
<pre>{error.message}</pre>
</div>
);
};

Query Key Strategies

Automatic Key Generation

By default, Magellan generates query keys from your service function name and arguments:

// Automatically generates key: ["getUserById", { id: 1 }]
const query1 = useQueryService({
serviceFn: getUserById,
serviceFnArgs: { id: 1 },
});

// Automatically generates key: ["getUserById", { id: 2 }]
const query2 = useQueryService({
serviceFn: getUserById,
serviceFnArgs: { id: 2 },
});

// These are different cache entries! ✅

Why this matters: You don't have to think about keys. Just call the hook and Magellan handles caching automatically.

Limitation: Function names must be extractable at runtime. Arrow functions assigned to const won't work:

// ❌ Won't work - anonymous function
const getUser = () => fetch("/api/user");

// ✅ Works - arrow function with name
const getUser = () => {
return fetch("/api/user");
};

// ✅ Works - arrow function with explicit name
const getUser = () => {
return fetch("/api/user");
};

Custom Query Keys

Provide custom keys when you need precise cache control:

const { data } = useQueryService({
serviceFn: getUserById,
serviceFnArgs: { id: 1 },
options: {
queryKey: ["users", 1, "profile"], // Custom key
},
});

When to Use Custom Keys

1. Multiple Queries to the Same Function

When you want different cache behavior for the same service function:

// Cache "current user" separately from "any user"
const currentUser = useQueryService({
serviceFn: getUserById,
serviceFnArgs: { id: currentUserId },
options: {
queryKey: ["currentUser"], // Special key
},
});

const otherUser = useQueryService({
serviceFn: getUserById,
serviceFnArgs: { id: 123 },
// Uses default key: ["getUserById", { id: 123 }]
});

2. Hierarchical Cache Invalidation

Organize keys by entity type for bulk invalidation:

// All user-related queries use the "users" prefix
const userProfile = useQueryService({
serviceFn: getUserProfile,
options: { queryKey: ["users", userId, "profile"] },
});

const userPosts = useQueryService({
serviceFn: getUserPosts,
options: { queryKey: ["users", userId, "posts"] },
});

// Later, invalidate all user data at once
queryClient.invalidateQueries({ queryKey: ["users", userId] });

3. Dynamic Filters or Pagination

Include filter state in the key:

type ProductListProps = {
category: string;
page: number;
};

const ProductList: FC<ProductListProps> = ({ category, page }) => {
const { data } = useQueryService({
serviceFn: getProducts,
serviceFnArgs: { category, page },
options: {
queryKey: ["products", category, page],
},
});

// Changing category or page creates new cache entries
return <Products items={data} />;
};

Query Invalidation and Cache Updates

Use the queryClient to manage the cache:

import { useQueryClient } from "@tanstack/react-query";

const CreateProduct: FC = () => {
const queryClient = useQueryClient();

const mutation = useMutationService({
serviceFn: createProduct,
});

const handleCreate = async (productData) => {
await mutation.mutate(productData);

// Invalidate and refetch product list
queryClient.invalidateQueries({ queryKey: ["products"] });
};

return <ProductForm onSubmit={handleCreate} />;
};

Common invalidation patterns:

// Invalidate all queries
queryClient.invalidateQueries();

// Invalidate all user queries
queryClient.invalidateQueries({ queryKey: ["users"] });

// Invalidate specific user
queryClient.invalidateQueries({ queryKey: ["users", userId] });

// Invalidate exact match only
queryClient.invalidateQueries({
queryKey: ["users", userId, "profile"],
exact: true
});

Custom QueryClient Configuration

Understanding Default Configuration

Magellan creates a default QueryClient with these settings:

const defaultQueryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 5 * 60 * 1000, // 5 minutes
gcTime: 10 * 60 * 1000, // 10 minutes (formerly cacheTime)
retry: 1, // Retry once on failure
refetchOnWindowFocus: false, // Don't refetch on tab focus
},
},
});

What these mean:

  • staleTime (5 minutes): Data is considered fresh for 5 minutes. Within this window, the hook returns cached data without refetching.
  • gcTime (10 minutes): Cached data is garbage collected 10 minutes after the last component using it unmounts.
  • retry (1): If a request fails, automatically retry once before showing an error.
  • refetchOnWindowFocus (false): Don't automatically refetch when the user returns to the browser tab.

Why these defaults? They balance freshness, performance, and user experience for typical applications.

Creating a Custom QueryClient

Create a custom QueryClient for different behavior:

import { QueryClient, QueryClientProvider } from "@tanstack/react-query";

// More aggressive caching
const aggressiveClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 30 * 60 * 1000, // 30 minutes
gcTime: 60 * 60 * 1000, // 1 hour
retry: 3, // Retry 3 times
refetchOnWindowFocus: false,
},
},
});

const App: FC = () => {
return (
<QueryClientProvider client={aggressiveClient}>
<YourApp />
</QueryClientProvider>
);
};

QueryClient Precedence

There are three ways to provide a QueryClient, with this precedence:

  1. Hook parameter (highest priority)
  2. React Context provider
  3. Magellan's default QueryClient (lowest priority)
// Priority 3: Default (if no provider or parameter)
const query1 = useQueryService({ serviceFn: getUser });

// Priority 2: From provider
const App: FC = () => {
const customClient = new QueryClient({ /* config */ });

return (
<QueryClientProvider client={customClient}>
{/* This uses customClient */}
<UserProfile />
</QueryClientProvider>
);
}

// Priority 1: From parameter (overrides provider)
const query2 = useQueryService({
serviceFn: getUser,
options: {
queryClient: verySpecialClient, // Overrides everything
},
});

Why this matters: You can set app-wide defaults via provider, but override for specific queries that need different behavior.

Performance Tuning

Scenario 1: Rarely-Changing Data

For data that changes infrequently (like configuration, country lists):

const staticDataClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: Infinity, // Never consider stale
gcTime: Infinity, // Never garbage collect
},
},
});

const { data } = useQueryService({
serviceFn: getCountries,
options: {
queryClient: staticDataClient,
},
});

Scenario 2: Real-Time Data

For frequently-changing data (like stock prices, live scores):

const realtimeClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 0, // Always stale, always refetch
gcTime: 1000, // Clean up quickly
refetchOnWindowFocus: true, // Refetch when tab focused
refetchInterval: 5000, // Poll every 5 seconds
},
},
});

const { data } = useQueryService({
serviceFn: getStockPrice,
serviceFnArgs: { symbol: "AAPL" },
options: {
queryClient: realtimeClient,
},
});

Scenario 3: Unreliable Network

For users on flaky connections:

const resilientClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 10 * 60 * 1000, // 10 minutes
retry: 5, // Retry 5 times
retryDelay: attemptIndex =>
Math.min(1000 * 2 ** attemptIndex, 30000), // Exponential backoff
},
},
});

When to Use Custom QueryClients

Use a custom QueryClient when:

  1. Different parts of your app have different caching needs

    • Admin dashboard needs fresh data, public pages can be stale
  2. You need different retry strategies

    • Critical operations retry aggressively, optional features fail fast
  3. You're testing

    • Tests need isolated QueryClients to prevent state leaking between tests
  4. You need specific TanStack Query features

    • Custom mutation observers, query filters, or persistence

Don't create custom QueryClients when:

  1. Default configuration works fine
  2. You're just starting out (optimize later)
  3. You can use per-query options instead (simpler)

Best Practices

1. Use Discriminated Unions for Type Safety

// ✅ Good - TypeScript knows data is defined when isSuccess is true
if (queryResult.isSuccess) {
console.log(queryResult.data.name); // data is guaranteed to be defined
}

// ❌ Avoid - Bypasses type safety
console.log(queryResult.data?.name);

2. Handle All States Explicitly

// ✅ Good - Handle all possible states
const MyComponent = () => {
const queryResult = useQueryService({
serviceFn: fetchData,
});

if (queryResult.isPending) return <LoadingSpinner />;
if (queryResult.isError) return <ErrorMessage error={queryResult.error} />;
if (queryResult.isSuccess) return <DataDisplay data={queryResult.data} />;

return null;
};

// ❌ Avoid - Missing error handling
const BadComponent = () => {
const { data } = useQueryService({ serviceFn: fetchData });
return <DataDisplay data={data} />; // What if data is undefined?
};

3. Distinguish Between Loading and Refetch Errors

// ✅ Good - Show stale data during refetch errors
const MyComponent = () => {
const queryResult = useQueryService({ serviceFn: fetchData });

if (queryResult.isPending) return <LoadingSpinner />;

if (queryResult.isError) {
if (queryResult.isLoadingError) {
return <ErrorMessage error={queryResult.error} />; // No data available
}
if (queryResult.isRefetchError) {
return (
<div>
<WarningBanner message="Failed to refresh data" />
<DataDisplay data={queryResult.data} /> {/* Stale data available */}
</div>
);
}
}

if (queryResult.isSuccess) return <DataDisplay data={queryResult.data} />;
};

4. Use Mutations for Data Modifications

// ✅ Good - Use mutation for creating data
const createUserMutation = useMutationService({
serviceFn: createUser,
});

// ❌ Avoid - Using query for mutations
const createUserQuery = useQueryService({
serviceFn: createUser, // This should be a mutation!
});

5. Leverage Automatic Query Keys

// ✅ Good - Let Magellan generate keys
const queryResult = useQueryService({
serviceFn: getUserById, // Automatically uses ["getUserById", args] as key
serviceFnArgs: { id: 1 },
});

// ⚠️ Only use custom keys when needed
const queryResult = useQueryService({
serviceFn: getUserById,
options: {
queryKey: ["users", 1, "profile"], // Only when you need specific cache control
},
});

6. Use enabled for Conditional Queries

// ✅ Good - Use enabled for conditional queries
const queryResult = useQueryService({
serviceFn: getUser,
serviceFnArgs: { id: userId },
options: {
enabled: !!userId, // Only run when userId exists
},
});

// ❌ Avoid - Conditional hook calls
if (userId) {
const queryResult = useQueryService({ serviceFn: getUser }); // Breaks Rules of Hooks!
}

7. Avoid Generic or Unclear Keys

// ❌ Bad - Too generic
const queryResult = useQueryService({
serviceFn: getUserById,
options: {
queryKey: ["data"], // What data?
},
});

// ✅ Good - Descriptive and hierarchical
const queryResult = useQueryService({
serviceFn: getUserById,
options: {
queryKey: ["users", userId, "profile"],
},
});

8. Invalidate Queries After Mutations

// ✅ Good - Keep cache fresh after mutations
const queryClient = useQueryClient();

const mutation = useMutationService({
serviceFn: updateUser,
});

const handleUpdate = async (userData) => {
await mutation.mutate(userData);
queryClient.invalidateQueries({ queryKey: ["users"] });
};

// ❌ Avoid - Stale data after mutation
const handleUpdate = async (userData) => {
await mutation.mutate(userData);
// User list still shows old data!
};

9. Test with Proper Mocking

// ✅ Good - Mock the hooks in tests
jest.mock("@quatico/magellan-react", () => ({
useQueryService: jest.fn(),
useMutationService: jest.fn(),
}));

// ❌ Avoid - Mocking service functions directly
jest.mock("./services", () => ({
getUserById: jest.fn(), // This doesn't test the hook behavior
}));

Real-World Examples

Example 1: User Profile Management with Optimistic Updates

import React, { FC, useState } from "react";
import { useQueryService, useMutationService } from "@quatico/magellan-react";
import { useQueryClient } from "@tanstack/react-query";
import { getUserProfile, updateUserProfile } from "./services";

type UserProfileEditorProps = {
userId: string;
};

const UserProfileEditor: FC<UserProfileEditorProps> = ({ userId }) => {
const queryClient = useQueryClient();
const [isEditing, setIsEditing] = useState(false);

// Fetch user profile
const {
data: profile,
isPending,
isError,
error,
} = useQueryService({
serviceFn: getUserProfile,
serviceFnArgs: { userId },
options: {
queryKey: ["users", userId, "profile"],
},
});

// Update mutation
const updateMutation = useMutationService({
serviceFn: updateUserProfile,
});

const handleSave = async (updatedData) => {
try {
await updateMutation.mutate({ userId, ...updatedData });

// Invalidate to refetch fresh data
queryClient.invalidateQueries({
queryKey: ["users", userId, "profile"]
});

setIsEditing(false);
} catch (error) {
console.error("Failed to update profile:", error);
}
};

if (isPending) return <LoadingSpinner />;
if (isError) return <ErrorPage error={error} />;

return (
<div>
{isEditing ? (
<ProfileForm
initialData={profile}
onSave={handleSave}
onCancel={() => setIsEditing(false)}
isSaving={updateMutation.isPending}
/>
) : (
<ProfileDisplay
profile={profile}
onEdit={() => setIsEditing(true)}
/>
)}
{updateMutation.isError && (
<ErrorAlert>{updateMutation.error.message}</ErrorAlert>
)}
{updateMutation.isSuccess && (
<SuccessAlert>Profile updated successfully!</SuccessAlert>
)}
</div>
);
};

Example 2: Search with Debouncing and Conditional Execution

import React, { FC, useState, useEffect } from "react";
import { useQueryService } from "@quatico/magellan-react";
import { searchProducts } from "./services";
import { useDebounce } from "./hooks/useDebounce";

const ProductSearch: FC = () => {
const [searchTerm, setSearchTerm] = useState("");
const debouncedSearchTerm = useDebounce(searchTerm, 500); // 500ms delay

// Only search when we have at least 3 characters
const {
data: results,
isPending,
isError,
error,
isEnabled,
} = useQueryService({
serviceFn: searchProducts,
serviceFnArgs: { query: debouncedSearchTerm },
options: {
enabled: debouncedSearchTerm.length >= 3,
queryKey: ["products", "search", debouncedSearchTerm],
},
});

return (
<div>
<input
type="text"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="Search products..."
/>

{searchTerm.length > 0 && searchTerm.length < 3 && (
<Hint>Type at least 3 characters to search</Hint>
)}

{isEnabled && isPending && <LoadingSpinner />}

{isError && <ErrorMessage>{error.message}</ErrorMessage>}

{results && results.length === 0 && (
<EmptyState>No products found for "{debouncedSearchTerm}"</EmptyState>
)}

{results && results.length > 0 && (
<SearchResults results={results} searchTerm={debouncedSearchTerm} />
)}
</div>
);
};

Example 3: Multi-Step Form with Mutation Chaining

import React, { FC, useState } from "react";
import { useMutationService } from "@quatico/magellan-react";
import { useQueryClient } from "@tanstack/react-query";
import {
createOrder,
processPayment,
sendConfirmationEmail
} from "./services";

type CheckoutWizardProps = {
cartItems: any[];
};

const CheckoutWizard: FC<CheckoutWizardProps> = ({ cartItems }) => {
const queryClient = useQueryClient();
const [step, setStep] = useState(1);
const [orderId, setOrderId] = useState<string | null>(null);

const createOrderMutation = useMutationService({
serviceFn: createOrder,
});

const processPaymentMutation = useMutationService({
serviceFn: processPayment,
});

const sendEmailMutation = useMutationService({
serviceFn: sendConfirmationEmail,
});

const handlePlaceOrder = async (shippingInfo) => {
try {
// Step 1: Create order
const order = await createOrderMutation.mutate({
items: cartItems,
shipping: shippingInfo,
});
setOrderId(order.id);
setStep(2);
} catch (error) {
console.error("Failed to create order:", error);
}
};

const handlePayment = async (paymentInfo) => {
try {
// Step 2: Process payment
await processPaymentMutation.mutate({
orderId,
payment: paymentInfo,
});
setStep(3);

// Step 3: Send confirmation (non-blocking)
sendEmailMutation.mutate({ orderId });

// Clear cart
queryClient.invalidateQueries({ queryKey: ["cart"] });
} catch (error) {
console.error("Payment failed:", error);
}
};

return (
<div>
{step === 1 && (
<ShippingForm
onSubmit={handlePlaceOrder}
isLoading={createOrderMutation.isPending}
error={createOrderMutation.error}
/>
)}

{step === 2 && (
<PaymentForm
onSubmit={handlePayment}
isLoading={processPaymentMutation.isPending}
error={processPaymentMutation.error}
/>
)}

{step === 3 && (
<ConfirmationPage
orderId={orderId}
emailSent={sendEmailMutation.isSuccess}
/>
)}
</div>
);
};

Example 4: Dashboard with Multiple Queries and Error Boundaries

import React, { FC } from "react";
import { ErrorBoundary } from "react-error-boundary";
import { useQueryService } from "@quatico/magellan-react";
import {
getDashboardStats,
getRecentOrders,
getTopProducts
} from "./services";

const Dashboard: FC = () => {
return (
<div className="dashboard">
<ErrorBoundary FallbackComponent={DashboardError}>
<StatsPanel />
</ErrorBoundary>

<ErrorBoundary FallbackComponent={PanelError}>
<RecentOrdersPanel />
</ErrorBoundary>

<ErrorBoundary FallbackComponent={PanelError}>
<TopProductsPanel />
</ErrorBoundary>
</div>
);
};

const StatsPanel: FC = () => {
const { data, isPending, isError, error } = useQueryService({
serviceFn: getDashboardStats,
options: {
queryKey: ["dashboard", "stats"],
},
});

if (isPending) return <StatsSkeleton />;
if (isError) return <StatsError error={error} />;

return (
<div className="stats-panel">
<StatCard title="Revenue" value={data.revenue} />
<StatCard title="Orders" value={data.orders} />
<StatCard title="Customers" value={data.customers} />
</div>
);
};

const RecentOrdersPanel: FC = () => {
const { data, isPending, isError, isRefetchError } = useQueryService({
serviceFn: getRecentOrders,
options: {
queryKey: ["dashboard", "recentOrders"],
},
});

if (isPending) return <TableSkeleton />;

return (
<div className="orders-panel">
{isRefetchError && (
<WarningBanner>
Failed to refresh. Showing cached data.
</WarningBanner>
)}
<OrdersTable orders={data} />
</div>
);
};

const TopProductsPanel: FC = () => {
const { data, isPending, isError } = useQueryService({
serviceFn: getTopProducts,
options: {
queryKey: ["dashboard", "topProducts"],
},
});

if (isPending) return <ChartSkeleton />;
if (isError) return <ChartError />;

return (
<div className="products-panel">
<ProductChart products={data} />
</div>
);
};

type DashboardErrorProps = {
error: Error;
};

const DashboardError: FC<DashboardErrorProps> = ({ error }) => {
return (
<div className="dashboard-error">
<h2>Dashboard Unavailable</h2>
<p>{error.message}</p>
<button onClick={() => window.location.reload()}>
Reload Dashboard
</button>
</div>
);
};

type PanelErrorProps = {
error: Error;
resetErrorBoundary: () => void;
};

const PanelError: FC<PanelErrorProps> = ({ error, resetErrorBoundary }) => {
return (
<div className="panel-error">
<p>Failed to load this panel</p>
<button onClick={resetErrorBoundary}>Try Again</button>
</div>
);
};

Example 5: File Upload with Progress Tracking

import React, { FC, useState } from "react";
import { useMutationService } from "@quatico/magellan-react";
import { uploadFile } from "./services";

const FileUploader: FC = () => {
const [file, setFile] = useState<File | null>(null);
const [uploadProgress, setUploadProgress] = useState(0);

const uploadMutation = useMutationService({
serviceFn: uploadFile,
});

const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const selectedFile = e.target.files?.[0];
if (selectedFile) {
setFile(selectedFile);
setUploadProgress(0);
}
};

const handleUpload = async () => {
if (!file) return;

try {
// Simulate progress (in real app, use XMLHttpRequest or fetch with progress)
const interval = setInterval(() => {
setUploadProgress((prev) => {
if (prev >= 90) {
clearInterval(interval);
return 90;
}
return prev + 10;
});
}, 200);

await uploadMutation.mutate({ file });

clearInterval(interval);
setUploadProgress(100);

// Reset after success
setTimeout(() => {
setFile(null);
setUploadProgress(0);
}, 2000);
} catch (error) {
console.error("Upload failed:", error);
setUploadProgress(0);
}
};

return (
<div className="file-uploader">
<input
type="file"
onChange={handleFileSelect}
disabled={uploadMutation.isPending}
/>

{file && (
<div className="file-info">
<p>{file.name}</p>
<p>{(file.size / 1024 / 1024).toFixed(2)} MB</p>
</div>
)}

{uploadMutation.isPending && (
<ProgressBar progress={uploadProgress} />
)}

{uploadMutation.isError && (
<ErrorAlert>{uploadMutation.error.message}</ErrorAlert>
)}

{uploadMutation.isSuccess && (
<SuccessAlert>File uploaded successfully!</SuccessAlert>
)}

<button
onClick={handleUpload}
disabled={!file || uploadMutation.isPending}
>
{uploadMutation.isPending ? "Uploading..." : "Upload File"}
</button>
</div>
);
};

type ProgressBarProps = {
progress: number;
};

const ProgressBar: FC<ProgressBarProps> = ({ progress }) => {
return (
<div className="progress-bar">
<div
className="progress-fill"
style={{ width: `${progress}%` }}
/>
<span>{progress}%</span>
</div>
);
};

API Reference

useQueryService

Hook for calling service functions that read data.

const useQueryService = <TFn extends ServiceFunction, TError = Error>(
config: UseQueryServiceConfig<TFn, TError>
): UseQueryServiceResult<TFn, TError> => { /* ... */ };

Configuration:

type UseQueryServiceConfig<TFn, TError> = {
// Required: The service function to call
serviceFn: TFn;

// Optional: Arguments to pass to the service function
serviceFnArgs?: Parameters<TFn>[0];

// Optional: Additional configuration
options?: {
// Custom query key (auto-generated from function name if not provided)
queryKey?: QueryKey;

// Whether the query runs automatically (default: true)
enabled?: boolean;

// Custom QueryClient instance
queryClient?: QueryClient;

// Any other TanStack Query options
...useQueryOptions
};
};

Return Value (Discriminated Union):

The return type is a discriminated union of five possible states:

// State 1: Query is pending (not started or disabled)
type MagellanQueryPendingResult = {
data: undefined;
error: null;
isPending: true;
isLoading: false;
isLoadingError: false;
isRefetchError: false;
isError: false;
isSuccess: false;
isEnabled: boolean;
status: "pending";
refetch: () => Promise<...>;
};

// State 2: First fetch in progress
type MagellanQueryLoadingResult = {
data: undefined;
error: null;
isPending: true;
isLoading: true;
isLoadingError: false;
isRefetchError: false;
isError: false;
isSuccess: false;
isEnabled: true;
status: "pending";
refetch: () => Promise<...>;
};

// State 3: Initial fetch failed (no data available)
type MagellanQueryLoadingErrorResult<TError> = {
data: undefined;
error: TError;
isPending: false;
isLoading: false;
isLoadingError: true;
isRefetchError: false;
isError: true;
isSuccess: false;
isEnabled: true;
status: "error";
refetch: () => Promise<...>;
};

// State 4: Background refetch failed (stale data available)
type MagellanQueryRefetchErrorResult<TData, TError> = {
data: TData;
error: TError;
isPending: false;
isLoading: false;
isLoadingError: false;
isRefetchError: true;
isError: true;
isSuccess: false;
isEnabled: true;
status: "error";
refetch: () => Promise<...>;
};

// State 5: Query succeeded
type MagellanQuerySuccessResult<TData> = {
data: TData;
error: null;
isPending: false;
isLoading: false;
isLoadingError: false;
isRefetchError: false;
isError: false;
isSuccess: true;
isEnabled: true;
status: "success";
refetch: () => Promise<...>;
};

Properties:

PropertyTypeDescription
dataTData | undefinedThe query result data
errorTError | nullThe error if query failed
isPendingbooleantrue when query hasn't completed initial fetch
isLoadingbooleantrue only during the first fetch
isLoadingErrorbooleantrue when initial fetch failed (no data available)
isRefetchErrorbooleantrue when background refetch failed (stale data available)
isErrorbooleantrue when any error occurred
isSuccessbooleantrue when query completed successfully
isEnabledbooleantrue when query is enabled
status"pending" | "error" | "success"The query status
refetchfunctionFunction to manually trigger a refetch

useMutationService

Hook for calling service functions that modify data.

const useMutationService = <TFn extends ServiceFunction, TError = Error>(
config: UseMutationServiceConfig<TFn, TError>
): UseMutationServiceResult<TFn, TError> => { /* ... */ };

Configuration:

type UseMutationServiceConfig<TFn, TError> = {
// Required: The service function to call
serviceFn: TFn;

// Optional: Additional configuration
options?: {
// Custom mutation key (auto-generated from function name if not provided)
mutationKey?: MutationKey;

// Any other TanStack Query mutation options
...useMutationOptions
};
};

Return Value (Discriminated Union):

The return type is a discriminated union of four possible states:

// State 1: Mutation hasn't been triggered yet
type MagellanMutationIdleResult = {
data: undefined;
error: null;
isPending: false;
isIdle: true;
isError: false;
isSuccess: false;
status: "idle";
mutate: (input: TInput) => Promise<TData>;
};

// State 2: Mutation is in progress
type MagellanMutationPendingResult = {
data: undefined;
error: null;
isPending: true;
isIdle: false;
isError: false;
isSuccess: false;
status: "pending";
mutate: (input: TInput) => Promise<TData>;
};

// State 3: Mutation failed
type MagellanMutationErrorResult<TError> = {
data: undefined;
error: TError;
isPending: false;
isIdle: false;
isError: true;
isSuccess: false;
status: "error";
mutate: (input: TInput) => Promise<TData>;
};

// State 4: Mutation succeeded
type MagellanMutationSuccessResult<TData> = {
data: TData;
error: null;
isPending: false;
isIdle: false;
isError: false;
isSuccess: true;
status: "success";
mutate: (input: TInput) => Promise<TData>;
};

Properties:

PropertyTypeDescription
dataTData | undefinedThe mutation result data
errorTError | nullThe error if mutation failed
isPendingbooleantrue when mutation is in progress
isIdlebooleantrue when mutation hasn't been triggered yet
isErrorbooleantrue when mutation failed
isSuccessbooleantrue when mutation succeeded
status"idle" | "pending" | "error" | "success"The mutation status
mutatefunctionFunction to trigger the mutation

QueryClient Configuration

Default configuration used by Magellan:

const defaultQueryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 5 * 60 * 1000, // 5 minutes
gcTime: 10 * 60 * 1000, // 10 minutes
retry: 1, // Retry once
refetchOnWindowFocus: false, // Don't refetch on focus
},
},
});

Common Configuration Options:

OptionTypeDefaultDescription
staleTimenumber300000 (5 min)Time in ms before data is considered stale
gcTimenumber600000 (10 min)Time in ms before inactive cache is garbage collected
retrynumber | boolean1Number of times to retry failed requests
retryDelayfunctionexponentialDelay between retries
refetchOnWindowFocusbooleanfalseRefetch when window regains focus
refetchIntervalnumber | falsefalsePolling interval in ms
enabledbooleantrueWhether query runs automatically

For complete TanStack Query options, see TanStack Query Documentation.