Documentation Index
Fetch the complete documentation index at: https://mintlify.com/danny-avila/librechat/llms.txt
Use this file to discover all available pages before exploring further.
Overview
LibreChat uses a layered state management approach:
- React Query - Server state and API data caching
- Jotai - Atomic local state with localStorage persistence
- Recoil - Legacy atomic state (being phased out in favor of Jotai)
- React Context - Feature-specific state sharing
React Query (Server State)
React Query handles all API interactions, caching, and server state synchronization.
Setup
From client/src/App.jsx:1:
import { QueryClient, QueryClientProvider, QueryCache } from '@tanstack/react-query';
import { useApiErrorBoundary } from './hooks';
const App = () => {
const { setError } = useApiErrorBoundary();
const queryClient = new QueryClient({
defaultOptions: {
queries: {
networkMode: 'always', // Attempt requests even offline (for localhost)
},
mutations: {
networkMode: 'always',
},
},
queryCache: new QueryCache({
onError: (error) => {
if (error?.response?.status === 401) {
setError(error); // Global error handling
}
},
}),
});
return (
<QueryClientProvider client={queryClient}>
{/* App content */}
</QueryClientProvider>
);
};
Query Hooks
Queries are defined in client/src/data-provider/ organized by feature.
Example: Agent Queries
From client/src/data-provider/Agents/queries.ts:1:
import { useQuery, useInfiniteQuery } from '@tanstack/react-query';
import { QueryKeys, dataService } from 'librechat-data-provider';
import type { UseQueryOptions, QueryObserverResult } from '@tanstack/react-query';
import type t from 'librechat-data-provider';
/**
* Hook for listing all Agents with pagination and sorting
*/
export const useListAgentsQuery = <TData = t.AgentListResponse>(
params: t.AgentListParams = defaultAgentParams,
config?: UseQueryOptions<t.AgentListResponse, unknown, TData>,
): QueryObserverResult<TData> => {
const queryClient = useQueryClient();
const endpointsConfig = queryClient.getQueryData<t.TEndpointsConfig>([QueryKeys.endpoints]);
const enabled = !!endpointsConfig?.[EModelEndpoint.agents];
return useQuery<t.AgentListResponse, unknown, TData>(
[QueryKeys.agents, params],
() => dataService.listAgents(params),
{
staleTime: 1000 * 5, // Consider data stale after 5s
refetchOnWindowFocus: false, // Don't refetch on window focus
refetchOnReconnect: false, // Don't refetch on reconnect
refetchOnMount: false, // Don't refetch on mount
retry: false, // Don't retry failed requests
enabled: config?.enabled !== undefined ? config.enabled && enabled : enabled,
...config,
},
);
};
/**
* Hook for getting a single agent by ID
*/
export const useGetAgentByIdQuery = (
agent_id: string | null | undefined,
config?: UseQueryOptions<t.Agent>,
): QueryObserverResult<t.Agent> => {
const isValidAgentId = !!agent_id && !isEphemeralAgent(agent_id);
return useQuery<t.Agent>(
[QueryKeys.agent, agent_id],
() => dataService.getAgentById({ agent_id: agent_id as string }),
{
refetchOnWindowFocus: false,
refetchOnReconnect: false,
refetchOnMount: false,
retry: false,
enabled: isValidAgentId && (config?.enabled ?? true),
...config,
},
);
};
From client/src/data-provider/queries.ts:85:
export const useConversationsInfiniteQuery = (
params: ConversationListParams,
config?: UseInfiniteQueryOptions<ConversationListResponse, unknown>,
) => {
const { isArchived, sortBy, sortDirection, tags, search } = params;
return useInfiniteQuery<ConversationListResponse>({
queryKey: [
isArchived ? QueryKeys.archivedConversations : QueryKeys.allConversations,
{ isArchived, sortBy, sortDirection, tags, search },
],
queryFn: ({ pageParam }) =>
dataService.listConversations({
isArchived,
sortBy,
sortDirection,
tags,
search,
cursor: pageParam?.toString(), // Cursor-based pagination
}),
getNextPageParam: (lastPage) => lastPage?.nextCursor ?? undefined,
keepPreviousData: true,
staleTime: 5 * 60 * 1000, // 5 minutes
cacheTime: 30 * 60 * 1000, // 30 minutes
...config,
});
};
Infinite Query Pattern:
- Use cursor-based pagination for large datasets
getNextPageParam extracts next cursor from response
keepPreviousData: true prevents UI flickering during refetch
Mutation Hooks
Mutations are defined in client/src/data-provider/mutations.ts:1.
Example: Update Conversation
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { dataService, QueryKeys } from 'librechat-data-provider';
import type { UseMutationResult } from '@tanstack/react-query';
import type * as t from 'librechat-data-provider';
import { updateConvoInAllQueries } from '~/utils';
export const useUpdateConversationMutation = (
id: string,
): UseMutationResult<
t.TUpdateConversationResponse,
unknown,
t.TUpdateConversationRequest,
unknown
> => {
const queryClient = useQueryClient();
return useMutation(
(payload: t.TUpdateConversationRequest) => dataService.updateConversation(payload),
{
onSuccess: (updatedConvo, payload) => {
const targetId = payload.conversationId || id;
// Update single conversation cache
queryClient.setQueryData([QueryKeys.conversation, targetId], updatedConvo);
// Update conversation in infinite query pages
updateConvoInAllQueries(queryClient, targetId, () => updatedConvo);
},
},
);
};
Optimistic Updates
From client/src/data-provider/mutations.ts:455:
export const useDeleteConversationMutation = (
options?: t.DeleteConversationOptions,
) => {
const queryClient = useQueryClient();
return useMutation(
(payload: t.TDeleteConversationRequest) =>
dataService.deleteConversation(payload),
{
// Cancel outgoing queries
onMutate: async () => {
await queryClient.cancelQueries([QueryKeys.allConversations]);
await queryClient.cancelQueries([QueryKeys.archivedConversations]);
},
// Update cache on success
onSuccess: (data, vars) => {
if (vars.conversationId) {
removeConvoFromAllQueries(queryClient, vars.conversationId);
}
// Remove from cache
queryClient.removeQueries({
queryKey: [QueryKeys.conversation, vars.conversationId],
exact: true,
});
// Invalidate list queries
queryClient.invalidateQueries({
queryKey: [QueryKeys.allConversations],
refetchPage: (_, index) => index === 0, // Only refetch first page
});
options?.onSuccess?.(data, vars);
},
},
);
};
Query Keys
All query keys are defined in packages/data-provider/src/keys.ts:1:
export enum QueryKeys {
messages = 'messages',
conversation = 'conversation',
allConversations = 'allConversations',
agents = 'agents',
agent = 'agent',
assistants = 'assistants',
endpoints = 'endpoints',
files = 'files',
tools = 'tools',
// ... more keys
}
export enum MutationKeys {
fileUpload = 'fileUpload',
updatePreset = 'updatePreset',
avatarUpload = 'avatarUpload',
// ... more keys
}
Query Key Patterns:
- Use enum for type safety
- Composite keys:
[QueryKeys.agent, agentId]
- Include relevant params:
[QueryKeys.agents, { limit, category }]
Jotai (Local State)
Jotai provides atomic state with localStorage persistence. Preferred over Recoil for new code.
Creating Atoms
From client/src/store/jotai-utils.ts:1:
import { atom } from 'jotai';
import { atomWithStorage } from 'jotai/utils';
/**
* Create a simple atom with localStorage persistence
*/
export function createStorageAtom<T>(key: string, defaultValue: T) {
return atomWithStorage<T>(key, defaultValue, undefined, {
getOnInit: true, // Load from storage on initialization
});
}
/**
* Create an atom with localStorage persistence and side effects
*/
export function createStorageAtomWithEffect<T>(
key: string,
defaultValue: T,
onWrite: (value: T) => void,
) {
const baseAtom = createStorageAtom(key, defaultValue);
return atom(
(get) => get(baseAtom),
(get, set, newValue: T) => {
set(baseAtom, newValue);
if (typeof window !== 'undefined') {
onWrite(newValue); // Trigger side effect
}
},
);
}
Example: Font Size Store
From client/src/store/fontSize.ts:1:
import { createStorageAtomWithEffect, initializeFromStorage } from './jotai-utils';
export const fontSizeAtom = createStorageAtomWithEffect(
'fontSize',
'16px',
(fontSize) => {
// Apply font size to DOM on change
document.documentElement.style.setProperty('--font-size', fontSize);
},
);
// Initialize on app startup
export const initializeFontSize = () => {
initializeFromStorage('fontSize', '16px', (fontSize) => {
document.documentElement.style.setProperty('--font-size', fontSize);
});
};
Tab-Isolated Storage
For state that should NOT sync across tabs:
import { createTabIsolatedAtom } from '~/store/jotai-utils';
// Each tab maintains independent state
export const favoritesToggleAtom = createTabIsolatedAtom(
'favoritesToggle',
false
);
Recoil (Legacy)
Recoil is being phased out in favor of Jotai. Used for conversation-specific state.
Atom Families
From client/src/store/agents.ts:1:
import { atomFamily, useRecoilCallback } from 'recoil';
import type { TEphemeralAgent } from 'librechat-data-provider';
export const ephemeralAgentByConvoId = atomFamily<TEphemeralAgent | null, string>({
key: 'ephemeralAgentByConvoId',
default: null,
effects: [
({ onSet, node }) => {
onSet(async (newValue) => {
const conversationId = node.key.split('__')[1]?.replaceAll('"', '');
logger.log('agents', 'Setting ephemeral agent:', { conversationId, newValue });
});
},
],
});
export function useUpdateEphemeralAgent() {
const updateEphemeralAgent = useRecoilCallback(
({ set }) =>
(convoId: string, agent: TEphemeralAgent | null) => {
set(ephemeralAgentByConvoId(convoId), agent);
},
[],
);
return updateEphemeralAgent;
}
Atom Families:
- Create atoms dynamically based on parameters (e.g., conversation ID)
- Useful for per-conversation state
- Use
useRecoilCallback for batch updates
1. Proper Dependency Arrays
// Bad: Missing dependencies
const memoizedValue = useMemo(() => {
return agent.name + agent.category;
}, []); // Missing agent dependency!
// Good: Complete dependencies
const memoizedValue = useMemo(() => {
return agent.name + agent.category;
}, [agent.name, agent.category]);
2. Query Invalidation
// Invalidate specific queries only
queryClient.invalidateQueries({
queryKey: [QueryKeys.allConversations],
refetchPage: (_, index) => index === 0, // Only refetch first page
});
// Cancel in-flight queries before mutations
await queryClient.cancelQueries([QueryKeys.allConversations]);
3. Selective Re-renders
// Use selector to extract specific data
const agentName = useRecoilValue(
ephemeralAgentByConvoId(convoId),
(agent) => agent?.name // Only re-render when name changes
);
4. Background Refetching
const { data } = useQuery(
[QueryKeys.agents],
fetchAgents,
{
staleTime: 5 * 60 * 1000, // Consider fresh for 5 minutes
cacheTime: 30 * 60 * 1000, // Keep in cache for 30 minutes
refetchOnWindowFocus: false, // Disable automatic refetch
}
);
Data Flow Architecture
┌─────────────────┐
│ Component │
└────────┬────────┘
│
├─ useQuery/useMutation (React Query)
│ └─ dataService (packages/data-provider)
│ └─ API endpoints
│
├─ useRecoilValue/useSetRecoilState (Recoil)
│ └─ Atom families (per-conversation state)
│
├─ useAtom (Jotai)
│ └─ Storage atoms (localStorage)
│
└─ useContext (React Context)
└─ Feature-specific state
Common Patterns
function AgentDetail({ agentId }: Props) {
const { data: agent, isLoading: agentLoading } = useGetAgentByIdQuery(agentId);
const { data: tools, isLoading: toolsLoading } = useAvailableAgentToolsQuery({
enabled: !!agent, // Only fetch tools when agent is loaded
});
if (agentLoading || toolsLoading) return <LoadingSpinner />;
return <div>{/* Render agent with tools */}</div>;
}
Conditional Mutations
function ConversationActions({ convoId }: Props) {
const deleteMutation = useDeleteConversationMutation({
onSuccess: () => {
// Navigate away after successful delete
navigate('/');
},
});
const handleDelete = () => {
if (confirm('Delete this conversation?')) {
deleteMutation.mutate({ conversationId: convoId });
}
};
return (
<button
onClick={handleDelete}
disabled={deleteMutation.isLoading}
>
{deleteMutation.isLoading ? 'Deleting...' : 'Delete'}
</button>
);
}
Next Steps