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
- Understanding the Basics
- Essential State Management
- Conditional Queries
- Advanced Error Handling
- Query Key Strategies
- Custom QueryClient Configuration
- Best Practices
- Real-World Examples
- API Reference
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:
- Call your service functions - No need to write fetch logic or handle HTTP
- Serialize/deserialize data - Automatically handled by Magellan's transport layer
- Manage cache - Intelligent caching based on query keys
- Provide discriminated unions - TypeScript knows exactly what data is available based on state
- 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:
- Loading Error: The initial fetch failed (no data available)
- 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:
- Hook parameter (highest priority)
- React Context provider
- 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:
-
Different parts of your app have different caching needs
- Admin dashboard needs fresh data, public pages can be stale
-
You need different retry strategies
- Critical operations retry aggressively, optional features fail fast
-
You're testing
- Tests need isolated QueryClients to prevent state leaking between tests
-
You need specific TanStack Query features
- Custom mutation observers, query filters, or persistence
Don't create custom QueryClients when:
- Default configuration works fine
- You're just starting out (optimize later)
- 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:
| Property | Type | Description |
|---|---|---|
data | TData | undefined | The query result data |
error | TError | null | The error if query failed |
isPending | boolean | true when query hasn't completed initial fetch |
isLoading | boolean | true only during the first fetch |
isLoadingError | boolean | true when initial fetch failed (no data available) |
isRefetchError | boolean | true when background refetch failed (stale data available) |
isError | boolean | true when any error occurred |
isSuccess | boolean | true when query completed successfully |
isEnabled | boolean | true when query is enabled |
status | "pending" | "error" | "success" | The query status |
refetch | function | Function 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:
| Property | Type | Description |
|---|---|---|
data | TData | undefined | The mutation result data |
error | TError | null | The error if mutation failed |
isPending | boolean | true when mutation is in progress |
isIdle | boolean | true when mutation hasn't been triggered yet |
isError | boolean | true when mutation failed |
isSuccess | boolean | true when mutation succeeded |
status | "idle" | "pending" | "error" | "success" | The mutation status |
mutate | function | Function 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:
| Option | Type | Default | Description |
|---|---|---|---|
staleTime | number | 300000 (5 min) | Time in ms before data is considered stale |
gcTime | number | 600000 (10 min) | Time in ms before inactive cache is garbage collected |
retry | number | boolean | 1 | Number of times to retry failed requests |
retryDelay | function | exponential | Delay between retries |
refetchOnWindowFocus | boolean | false | Refetch when window regains focus |
refetchInterval | number | false | false | Polling interval in ms |
enabled | boolean | true | Whether query runs automatically |
For complete TanStack Query options, see TanStack Query Documentation.