Architecture Overview
The data-fetching architecture is layered:
React Query (state/cache management)
-> Zeus api layer (type-safe fetching)
-> GraphQL server
- •Zeus api layer (
api/client.ts,api/query.ts,api/mutation.ts) handles type-safe GraphQL communication - •React Query (
@tanstack/react-query) manages server state, caching, loading/error states, and cache invalidation - •Components use React Query hooks (
useQuery/useMutation) which call Zeus internally
GraphQL API Layer with Zeus (Foundation)
Client Setup (api/client.ts)
import { Chain } from '../zeus/index';
// Create chain with cookie credentials - auth token sent automatically via httpOnly cookie
export const createChain = () => {
return Chain('/graphql', {
headers: {
'Content-Type': 'application/json',
},
credentials: 'same-origin',
});
};
Query/Mutation Helpers (api/query.ts, api/mutation.ts)
// api/query.ts
import { createChain } from './client';
export const query = () => createChain()('query');
// api/mutation.ts
import { createChain } from './client';
export const mutation = () => createChain()('mutation');
Direct Usage (for non-component contexts)
import { query, mutation } from '../api';
// Fetching data
const data = await query()({
user: {
me: { _id: true, email: true },
},
});
// Mutations
await mutation()({
user: {
changePassword: [{ newPassword: 'new-secret' }, true],
},
});
// Login
const data = await mutation()({
login: [{ email, password }, true],
});
Advanced Zeus Patterns
Using Selectors
Selectors define reusable query shapes and derive TypeScript types:
import { Selector, type FromSelector } from '../zeus/index.js';
// Define selector
const postSelector = Selector('Post')({
_id: true,
title: true,
content: true,
published: true,
});
// Derive type from selector (instead of manual type definition)
type PostType = FromSelector<typeof postSelector, 'Post'>;
// Use in query
const data = await query()({
user: { posts: postSelector },
});
Selector Placement Rule
- •Used in 1 file only → define locally in that file
- •Used in 2+ files → define in
api/selectors.tsand re-export fromapi/index.ts
Shared selectors file (api/selectors.ts):
import { Selector, type FromSelector } from '../zeus/index.js';
export const postSelector = Selector('Post')({
_id: true,
title: true,
content: true,
published: true,
});
export type PostType = FromSelector<typeof postSelector, 'Post'>;
Re-export from api/index.ts:
export { postSelector, type PostType } from './selectors.js';
GraphQL Variables with $
Use $ for parameterized queries:
import { $ } from '../zeus/index';
// With variables
const result = await mutation()(
{
login: [
{
email: $('email', 'String!'),
password: $('password', 'String!'),
},
true,
],
},
{
variables: { email: 'john@example.com', password: 'secret' },
},
);
React Query Integration
React Query wraps the Zeus api layer to provide caching, automatic loading/error states, and cache invalidation.
Setup
QueryClient configuration (lib/queryClient.ts):
import { QueryClient } from '@tanstack/react-query';
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 5, // 5 minutes
retry: 1,
refetchOnWindowFocus: false,
},
},
});
Provider setup in App.tsx:
import { QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { queryClient } from './lib/queryClient';
// Wrap app with QueryClientProvider
<QueryClientProvider client={queryClient}>
{children}
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
Pattern: useQuery + Zeus
⚠️ Use
isLoading, notisPendingfor UI loading states.isPendingistruewhen query is disabled (enabled: false), causing permanent loading states.isLoading(isPending && isFetching) is onlytrueduring actual fetches.
Zeus query() is used inside React Query's queryFn:
import { useQuery } from '@tanstack/react-query';
import { useAuthStore } from '../stores';
import { query } from '../api';
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
const { data, isLoading, error } = useQuery({
queryKey: ['posts'],
queryFn: async () => {
const data = await query()({
user: { posts: { _id: true, title: true, content: true, published: true } },
});
return data.user?.posts ?? [];
},
enabled: isAuthenticated, // only fetch when authenticated
});
Pattern: useMutation + Zeus
Zeus mutation() is used inside React Query's mutationFn, with cache invalidation on success:
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { mutation } from '../api';
const queryClient = useQueryClient();
const createPostMutation = useMutation({
mutationFn: async (input: { title: string; content: string }) => {
await mutation()({
user: { createPost: [input, true] },
});
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['posts'] });
},
});
// Use with .mutateAsync() for imperative flows
const createPost = async (input: { title: string; content: string }) => {
try {
await createPostMutation.mutateAsync(input);
return true;
} catch {
return false;
}
};
Query Key Conventions
Query keys are simple string arrays:
| Query Key | Purpose |
|---|---|
['posts'] | Post list data |
['me'] | Current authenticated user |
For more complex apps, consider a key factory pattern (e.g., postKeys.all(), postKeys.detail(id)).
Cache Invalidation Patterns
- •After mutations:
queryClient.invalidateQueries({ queryKey: ['posts'] }) - •From subscriptions: Same invalidation in subscription callbacks
- •On logout:
queryClient.clear()(clears ALL cache -- security measure)
Subscription -> invalidation bridge:
const queryClient = useQueryClient();
usePostSubscription({
ownerId: user?._id ?? null,
onPostCreated: () => queryClient.invalidateQueries({ queryKey: ['posts'] }),
onPostUpdated: () => queryClient.invalidateQueries({ queryKey: ['posts'] }),
});
Conditional Queries
Use enabled to prevent queries from running until preconditions are met:
const { data } = useQuery({
queryKey: ['me'],
queryFn: async () => {
/* ... */
},
enabled: isAuthenticated, // don't fetch until authenticated
retry: (failureCount, error) => {
if (error instanceof Error && error.message.includes('Unauthorized')) return false;
return failureCount < 1;
},
});
Error Aggregation Pattern
Aggregate errors from query + multiple mutations into a single value:
const error = queryError?.message ?? loginMutation.error?.message ?? registerMutation.error?.message ?? null;
Key Rules
- •ALWAYS use React Query (
useQuery/useMutation) for data fetching in components -- never manualuseState/useEffectfetching - •Use Zeus
query()/mutation()inside React Query'squeryFn/mutationFn - •Use
enabledoption for conditional queries (e.g.,enabled: isAuthenticated) - •Invalidate cache after mutations with
queryClient.invalidateQueries() - •Clear cache on logout with
queryClient.clear() - •ALWAYS use Zeus for GraphQL communication -- never write raw GraphQL queries
- •Use the api/ layer -- import from
../apinot directly from Zeus - •ALWAYS define Selectors for reusable query shapes
- •ALWAYS use
FromSelectorto derive TypeScript types from selectors - •NEVER manually duplicate backend types -- derive them from selectors
- •Selector used in 1 file → keep local; used in 2+ files → put in
api/selectors.ts - •Use
$function for GraphQL variables when values come from user input or props - •One hook per domain —
useAuthfor authentication,usePostsfor post operations. Each hook owns its queries, mutations, error aggregation, and loading states. Keep components presentational — all data logic lives in hooks
Quick Reference
| Task | Code |
|---|---|
| Create query | query()({ user: { posts: { _id: true } } }) |
| Create mutation | mutation()({ login: [{ email, password }, true] }) |
| Mutation with args | mutation()({ field: [{ arg: value }, selector] }) |
| Return scalar directly | field: [{ args }, true] |
| Fetch with React Query | useQuery({ queryKey: ['key'], queryFn: () => query()({...}) }) |
| Mutate with React Query | useMutation({ mutationFn: (args) => mutation()({...}) }) |
| Invalidate cache | queryClient.invalidateQueries({ queryKey: ['key'] }) |
| Conditional query | useQuery({ ..., enabled: isAuthenticated }) |
| Define selector | const sel = Selector('Post')({ _id: true, ... }) |
| Derive type | type T = FromSelector<typeof sel, 'Post'> |
| Clear all cache | queryClient.clear() |
Troubleshooting
Type errors after schema changes
Solution: Regenerate Zeus by running cd backend && npx @aexol/axolotl build
Zeus files not found
Zeus Configuration: See
AGENTS.md→ Understanding axolotl.json for the Zeus generation config. Thezeusarray inaxolotl.jsondefines output paths for generated Zeus client files.
Solution: Verify backend/axolotl.json contains a zeus array pointing to your frontend source directory, then run cd backend && npx @aexol/axolotl build to regenerate.