elek.io Desktop uses TanStack Query (formerly React Query) for all data fetching and mutations. This provides a consistent, type-safe way to interact with the main process via IPC, with built-in caching, loading states, and error handling.
The base configuration for all queries and mutations is in client.ts:
Key Settings:
staleTime: Infinity(line 9) - Queries remain "fresh" indefinitely and won't automatically refetch. This is appropriate for local data that only changes through user actions.
All queryOptions and mutationOptions are centrally defined in the options/ directory with separate files for each domain (projectOptions.ts, collectionOptions.ts, entryOptions.ts, assetOptions.ts, userOptions.ts, apiOptions.ts) and re-exported through queries/index.ts. These files contain:
- Typed wrappers around IPC calls
- Query keys for cache management
- Automatic cache updates on mutations - Mutations automatically update related queries' cache data. For example, when you create a Project, both the individual Project cache AND the Projects list cache are updated without requiring a refetch.
- Metadata for toast notifications (method and objectType)
Query keys follow a consistent hierarchy so individual items, lists and historical states can be cached and invalidated independently. Each level is followed by a state marker ('current' for HEAD or a commit hash for a historical state), and list queries end in 'list':
// all projects
['projects', 'list'];
// a project at HEAD / at a specific commit
['projects', projectId, 'current'];
['projects', projectId, commitHash];
// uncommitted git changes of a project
['projects', projectId, 'current', 'changes'];
// commit history of a project
['projects', projectId, 'history'];
// collections in a project
['projects', projectId, 'current', 'collections', 'list'];
// a collection at HEAD
['projects', projectId, 'current', 'collections', collectionId, 'current'];
// entries in a collection
[
'projects',
projectId,
'current',
'collections',
collectionId,
'current',
'entries',
'list',
];
// an entry at HEAD
[
'projects',
projectId,
'current',
'collections',
collectionId,
'current',
'entries',
entryId,
'current',
];For accessing the current user and project data, prefer using the useUser() and useProject() hooks instead of directly calling useQuery() with queryOptions.user.get() or queryOptions.projects.read().
Benefits:
- Reduces the number of active query observers - the providers create a single query that's shared across all components
- Provides convenient utility methods (
formatDatetime,translateContent) that automatically use the current user/project context - Ensures consistent data access patterns throughout the application
- Better performance with fewer unnecessary query subscriptions
The UserProvider wraps the entire application at the root level (see routes/__root.tsx:131). It provides:
userQuery: UseQueryResultNoError<User | null>- The current user query resultformatDatetime: (props: FormatDatetimeProps) => { relative: string; absolute: string }- Formats datetime strings according to the user's locale
Example: See components/user-header.tsx
import { useUser } from '@renderer/hooks/useUser';
function UserHeader() {
const { userQuery, formatDatetime } = useUser();
// Access user data
const user = userQuery.data;
// Format a datetime
const formattedDate = formatDatetime({ datetime: user?.createdAt });
return (
<div>
<p>{user?.name}</p>
<p>Joined {formattedDate.relative}</p>
</div>
);
}The ProjectProvider wraps all project-specific routes (see routes/projects/$projectId.tsx:32). It provides:
projectId: string- The current project ID from the routeprojectQuery: UseQueryResultNoError<Project>- The current project query resultuserQuery: UseQueryResultNoError<User | null>- The current user query result (inherited from UserProvider)formatDatetime: (props: FormatDatetimeProps) => { relative: string; absolute: string }- Datetime formatter (inherited from UserProvider)translateContent: (props: TranslateContentProps) => string- Translates user-defined content to the current user's language, falling back to the project's default language, then English
Example: See components/project-sidebar.tsx
import { useProject } from '@renderer/hooks/useProject';
function ProjectSettings() {
const { projectQuery, translateContent } = useProject();
const { data: project, isPending: isReadingProject } = projectQuery;
if (isReadingProject) return <LoadingSkeleton />;
// project (and its name) is now defined
const title = translateContent({
key: 'project.title',
record: project.name, // TranslatableString object
});
return <h1>{title}</h1>;
}A Project's commit history is not part of the Project read result. Fetch it with queryOptions.projects.history({ id }), which calls core.projects.history() through the core:projects:history IPC channel (exposed in preload/index.ts, handled in main/index.ts, and typed in index.d.ts). It returns a ProjectHistoryResult with a history array (changes to the Project itself, e.g. its settings) and a fullHistory array (every commit in the Project). The commit-detail and diff views, the history sidebar, and the dashboard's latest-changes widget all read fullHistory from this query.
const { data: history } = useQueryNoError(
queryOptions.projects.history({ id: projectId })
);
// history?.fullHistory is the full git log, history?.history is Project-only changesWe prioritize fast, responsive UI by rendering pages immediately without waiting for data to load. Navigation between pages should feel instantaneous.
Pattern: Show Skeleton components while data loads, then replace them with actual content when ready.
Example: See routes/projects/index.tsx
const { data: projects, isPending: isListingProjects } = useQueryNoError(
queryOptions.projects.list({ limit: 0 })
);
return (
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 xl:grid-cols-3">
{isListingProjects
? [1, 2, 3, 4, 5].map((i) => <ProjectCardSkeleton key={i} />)
: projects.list.map((project) => (
<ProjectCard key={project.id} project={project} />
))}
</div>
);This ensures the page structure renders immediately, with placeholders that are replaced by real data as it loads.
Always use useQueryNoError instead of useQuery for data fetching.
This hook solves a TypeScript limitation where data is typed as T | undefined even with throwOnError: true. It manually throws errors to the error boundary and returns a narrowed type without error states.
Example: See routes/projects/$projectId/collections/$collectionId/index.tsx
import { useQueryNoError } from '@renderer/hooks/useQueryNoError';
const { data: collection, isPending } = useQueryNoError(
queryOptions.collections.read({ projectId, id: collectionId })
);
if (isPending) return <LoadingSkeleton />;
// data is now safely typed as defined (no error or loading states)
return <div>{collection.name}</div>;See the hook's JSDoc for implementation details. Errors are caught by the root ErrorComponent in routes/__root.tsx.
For fetching a variable-length list of same-typed queries (for example one entries.list per Collection), use the sibling useQueriesNoError hook, which applies the same error narrowing to an array of query options.
Naming Conventions:
When destructuring useQueryNoError, follow these patterns based on the query operation:
data→ Name it as the resulting object (e.g.,project,collection,entries)isPending→ Prefix with the query operation (e.g.,isReadingProject,isListingProjects,isReadingCollection)
Always look at the queryOptions method used to determine the correct names:
// queryOptions.projects.read() → reading a single project
const { data: project, isPending: isReadingProject } = useQueryNoError(
queryOptions.projects.read({ id: projectId })
);
// queryOptions.projects.list() → listing multiple projects
const { data: projects, isPending: isListingProjects } = useQueryNoError(
queryOptions.projects.list({ limit: 0 })
);
// queryOptions.collections.read() → reading a single collection
const { data: collection, isPending: isReadingCollection } = useQueryNoError(
queryOptions.collections.read({ projectId, id: collectionId })
);Component props vs query state:
Query state uses isPending (TanStack Query's term). The Button component, however, accepts an isLoading prop for component API consistency. Both are intentional - pass the query's pending state into the button's isLoading prop:
const { mutateAsync: createProject, isPending: isCreatingProject } = useMutation(
queryOptions.projects.create
);
<Button isLoading={isCreatingProject}>Create</Button>;When building forms to update existing data, we need to handle the loading state gracefully. Forms need to be populated with current values, but those values come from a query that may not be resolved yet.
Pattern: Use <fieldset disabled={isPending}> to disable the entire form while data loads, instead of showing loading spinners or skeleton forms.
Example: See routes/user/profile.tsx
// Source the user from the useUser() hook (see "Using Context Providers" above)
const {
userQuery: { data: user, isPending: isGettingUser },
} = useUser();
const setUserForm = useForm<SetUserProps>({
defaultValues: { /* ... */ }
});
// Reset the form with user data once it loads
useEffect(() => {
if (user) {
setUserForm.reset(user);
}
}, [user, setUserForm]);
return (
<Form {...setUserForm}>
<form>
<fieldset disabled={isGettingUser}>
{/* Form fields here - automatically disabled while loading */}
<FormField name="name" /* ... */ />
<FormField name="email" /* ... */ />
</fieldset>
</form>
</Form>
);Benefits:
- No need for skeleton components in forms
- Form structure renders immediately
- Native browser styling for disabled state
- Prevents user interaction during loading
- Cleaner code with less conditional rendering
All mutations should include metadata for proper toast notifications and logging. Use the customMutationOptions wrapper from util.ts to not only make sure the metadata is included, but also to automatically add throwOnError: true to propagate errors to the nearest error boundary and log all mutations with Core:
customMutationOptions({
mutationFn: async () => {
/* ... */
},
meta: {
method: 'create',
objectType: 'project',
},
// ...
});This metadata is used by the query client to automatically display success/error toasts and log all mutations for debugging.
All mutations in the options/ directory include onSuccess handlers that automatically update the cache. This ensures the UI reflects changes immediately without requiring refetches.
Located at util.ts:144-195, this helper function merges individual objects into paginated list caches:
function mergeListWithObject<T extends BaseFile>(
oldList: PaginatedList<T> | undefined,
object: T,
method?: 'update' | 'delete'
): PaginatedList<T>;Behavior:
- No list exists: Creates new list with the object
- Object doesn't exist in list: Adds it to the end
- Object exists + method='update': Replaces the object
- Object exists + method='delete': Removes the object
Usage in Mutations:
// Create mutation - add to list
onSuccess: (createdProject, _variables, _result, context) => {
context.client.setQueryData(
['projects', createdProject.id, 'current'],
createdProject
);
context.client.setQueryData<PaginatedList<Project>>(
['projects', 'list'],
(oldList) => mergeListWithObject(oldList, createdProject) // Adds to list
);
};
// Update mutation - update in list
onSuccess: (updatedProject, _variables, _result, context) => {
context.client.setQueryData(
['projects', updatedProject.id, 'current'],
updatedProject
);
context.client.setQueryData<PaginatedList<Project>>(
['projects', 'list'],
(oldList) => mergeListWithObject(oldList, updatedProject, 'update') // Updates in list
);
};
// Delete mutation - remove from list
onSuccess: (_deletedProject, variables, _result, context) => {
context.client.setQueryData(['projects', variables.id, 'current'], undefined);
context.client.setQueryData<PaginatedList<Project>>(
['projects', 'list'],
(oldList) =>
mergeListWithObject(oldList, { id: variables.id } as Project, 'delete') // Removes from list
);
};List queries automatically populate individual item caches to avoid redundant fetches:
Example from project options (see options/projectOptions.ts):
list: (props?: ListProjectsProps) =>
queryOptions({
queryKey: ['projects', 'list'],
queryFn: async () => {
const projects = await window.ipc.core.projects.list(props);
// Cache each project individually too
// so we can access them directly without refetching later
projects.list.forEach((project) => {
queryClient.setQueryData(['projects', project.id, 'current'], project);
});
return projects;
},
throwOnError: true,
});Benefit: When navigating to a project detail page after viewing the list, the data is already cached - no loading spinner needed.
Two approaches for cache updates:
Use setQueryData with mergeListWithObject for operations where you know exactly what changed:
onSuccess: (updatedItem, variables, _result, context) => {
// Update both individual and list caches
context.client.setQueryData(['items', updatedItem.id], updatedItem);
context.client.setQueryData(['items', 'list'], (oldList) =>
mergeListWithObject(oldList, updatedItem, 'update')
);
};Pros: Instant UI updates, no network/IPC calls
Use for: create, update, delete, user settings, API start/stop
Use invalidateQueries for operations that may affect multiple caches:
onSuccess: async (_data, variables, _result, context) => {
await context.client.invalidateQueries({
queryKey: ['projects', variables.id],
refetchType: 'all', // Refetch all matching queries
});
};Pros: Safe when scope is unknown, handles cascading updates
Cons: Triggers refetches (slower), may refetch unnecessary data
Use for: synchronize (git pull), setRemoteOriginUrl
Do not use router.invalidate() after mutations:
// Invalidates all queries for the route
const onSave = async (data) => {
await saveMutation(data);
await router.invalidate(); // Too broad, redundant
};Let mutations handle their own cache updates:
Mutations already have onSuccess handlers that update the necessary caches. Using router.invalidate() is redundant and invalidates unrelated queries.
The desktop app handles errors in three layers:
- Route error boundaries - the root
ErrorComponentinroutes/__root.tsxcatches all unhandled errors, logs them to the Core logger, and shows a friendly error page with a way back to the projects list. Sentry capture for React errors is wired separately, via Sentry'sreactErrorHandlerpassed toReactDOM.createRootinapp.tsx(Sentry itself is initialized inindex.ts). - Query and mutation errors -
throwOnError: trueis set by default (viauseQueryNoErrorandcustomMutationOptions), so query errors bubble to the nearest error boundary and mutation failures additionally show a toast through the global handler. - Core logger - all errors, mutations and navigations are logged through Core, accessible via
window.ipc.core.logger.