.agents/skills/frontend-query-mutation/references/runtime-rules.md
mutate vs mutateAsyncUse the enabled option to conditionally run queries. Langflow hooks pass through options to UseRequestProcessor, which passes them to useQuery.
// Pattern: Disable query when not authenticated
export const useGetGlobalVariables: useQueryFunctionType<
undefined,
GlobalVariable[]
> = (options?) => {
const { query } = UseRequestProcessor()
const isAuthenticated = useAuthStore((state) => state.isAuthenticated)
const getGlobalVariablesFn = async (): Promise<GlobalVariable[]> => {
if (!isAuthenticated) return []
const res = await api.get(`${getURL("VARIABLES")}/`)
return res.data
}
return query(["useGetGlobalVariables"], getGlobalVariablesFn, {
refetchOnWindowFocus: false,
enabled: isAuthenticated && (options?.enabled ?? true),
...options,
})
}
// Pattern: Disable query when required param is missing
export const useGetFlow: useQueryFunctionType<{ id: string }, FlowResponse> = (
params,
options?,
) => {
const { query } = UseRequestProcessor()
const getFlowFn = async (): Promise<FlowResponse> => {
const res = await api.get(`${getURL("FLOWS")}/${params.id}`)
return res.data
}
return query(["useGetFlow", params.id], getFlowFn, {
enabled: !!params.id && (options?.enabled ?? true),
...options,
})
}
// Pattern: Consumer disables query via options
const { data: flow } = useGetFlow(
{ id: flowId },
{ enabled: showFlowDetails },
)
Rules:
options?.enabled ?? true when combining with other conditions, so consumers can also disable the query.enabled instead.Bind invalidation in the mutation hook definition. Components should only add UI feedback (toasts, navigation) in call-site callbacks, not decide which queries to invalidate.
The UseRequestProcessor.mutate() wrapper already calls queryClient.invalidateQueries({ queryKey: mutationKey }) in its default onSettled. For additional invalidation, extend onSettled:
export const usePostAddFlow: useMutationFunctionType<
undefined,
PostAddFlowPayload
> = (options?) => {
const { mutate, queryClient } = UseRequestProcessor()
const myCollectionId = useFolderStore((state) => state.myCollectionId)
const postAddFlowFn = async (payload: PostAddFlowPayload): Promise<any> => {
const response = await api.post(`${getURL("FLOWS")}/`, payload)
return response.data
}
return mutate(["usePostAddFlow"], postAddFlowFn, {
onSettled: (response) => {
if (response) {
queryClient.refetchQueries({
queryKey: ["useGetRefreshFlowsQuery", { get_all: true, header_flows: true }],
})
queryClient.refetchQueries({
queryKey: ["useGetFolder", response.folder_id ?? myCollectionId],
})
}
},
...options, // Consumer options come LAST
})
}
// Broad invalidation: all queries for a domain
queryClient.invalidateQueries({ queryKey: ["useGetFlows"] })
// Specific invalidation: single cache entry
queryClient.invalidateQueries({ queryKey: ["useGetFlow", flowId] })
// Refetch instead of invalidate when you need data immediately
queryClient.refetchQueries({ queryKey: ["useGetFolder", folderId] })
// Component only adds UI behavior
const { mutate: addFlow } = usePostAddFlow()
const handleCreate = () => {
addFlow(flowData, {
onSuccess: (response) => {
// UI-only: navigate, show toast
navigate(`/flow/${response.id}`)
setSuccessData({ title: "Flow created successfully" })
},
onError: (error) => {
setErrorData({
title: "Failed to create flow",
list: [error.message],
})
},
})
}
// Base key: hook name
["useGetGlobalVariables"]
// Parameterized key: hook name + params
["useGetFlow", flowId]
["useGetFolder", folderId]
// Complex key: hook name + param object
["useGetRefreshFlowsQuery", { get_all: true, header_flows: true }]
["useGetMessages", { flowId, sessionId }]
["useGetBuilds", { flowId }]
// Mutation key: hook name (used for automatic invalidation by UseRequestProcessor)
["usePostAddFlow"]
["useDeleteMessages"]
Rules:
UseRequestProcessor.mutate() auto-invalidates the mutationKey on settled.onSettled.mutate vs mutateAsyncPrefer mutate by default. Use mutateAsync only when Promise semantics are truly required.
Rules:
mutate(...) with onSuccess or onError.await mutateAsync(...) must be wrapped in try/catch.mutateAsync when callbacks already express the flow clearly.// Default: use mutate with callbacks
const { mutate: deleteFlow } = useDeleteFlow()
const handleDelete = () => {
deleteFlow(flowId, {
onSuccess: () => {
navigate("/flows")
setSuccessData({ title: "Flow deleted" })
},
onError: (error) => {
setErrorData({ title: "Delete failed", list: [error.message] })
},
})
}
// Exception: Promise semantics needed for sequential operations
const handleDuplicateAndOpen = async () => {
try {
const newFlow = await duplicateFlow.mutateAsync(flowData)
await renameFlow.mutateAsync({ id: newFlow.id, name: `${flowData.name} (copy)` })
navigate(`/flow/${newFlow.id}`)
} catch (error) {
setErrorData({
title: "Failed to duplicate flow",
list: [error instanceof Error ? error.message : "Unknown error"],
})
}
}
The ApiInterceptor in controllers/API/api.tsx automatically handles:
useRefreshAccessToken, then retries the request.You do not need to handle 401/403 errors in individual hooks or components.
Handle errors at the call site for user-facing feedback:
const { mutate: saveFlow } = useSaveFlow()
const handleSave = () => {
saveFlow(flowData, {
onError: (error) => {
setErrorData({
title: "Failed to save flow",
list: [error.response?.data?.detail ?? error.message],
})
},
})
}
For queries, React Query's retry logic (5 retries with exponential backoff via UseRequestProcessor) handles transient failures. For permanent errors, use onError in options or error boundaries:
const { data, error, isError } = useGetFlow(
{ id: flowId },
{
retry: false, // Override default retry for known-missing resources
onError: (error) => {
if (error.response?.status === 404) {
navigate("/flows")
}
},
},
)
Langflow uses performStreamingRequest() from controllers/API/api.tsx for build and chat streaming. This uses the browser fetch API (not Axios) with Server-Sent Events (SSE) parsing.
import { performStreamingRequest } from "@/controllers/API/api"
const buildController = new AbortController()
await performStreamingRequest({
method: "POST",
url: `${baseURL}/api/v1/build/${flowId}/flow`,
body: { inputs, files },
buildController,
onData: async (event) => {
// Process individual SSE events
// Return true to continue, false to abort
return true
},
onDataBatch: async (events) => {
// Process batch of events from a single chunk (more efficient)
// Return true to continue, false to abort
return true
},
onError: (statusCode) => {
// Handle HTTP error status
},
onNetworkError: (error) => {
// Handle network-level errors
},
})
| Operation | Method |
|---|---|
| Build flow | performStreamingRequest() with SSE |
| Chat interaction | performStreamingRequest() with SSE |
| CRUD operations (flows, folders, variables) | Axios api instance via query/mutation hooks |
| File upload/download | Axios api instance |
| Auth operations | Axios api instance |
Use AbortController to cancel streaming operations:
const buildController = useRef(new AbortController())
const handleStopBuild = () => {
buildController.current.abort()
buildController.current = new AbortController()
}
The UseRequestProcessor hook in controllers/API/services/request-processor.ts provides:
{
retry: 5,
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000),
// 1s, 2s, 4s, 8s, 16s (capped at 30s)
}
The actual mutate() implementation in request-processor.ts:
function mutate(mutationKey, mutationFn, options = {}) {
return useMutation({
mutationKey,
mutationFn,
onSettled: (data, error, variables, context) => {
queryClient.invalidateQueries({ queryKey: mutationKey });
options.onSettled && options.onSettled(data, error, variables, context);
},
...options, // Spreads AFTER the wrapper onSettled
retry: options.retry ?? 3, // Comes AFTER the spread
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000),
});
}
How onSettled actually works (important subtlety):
UseRequestProcessor.mutate() defines a wrapper onSettled that (a) auto-invalidates the mutationKey, then (b) calls the hook's options.onSettled....options spread comes AFTER the wrapper, so if the hook's options object contains its own onSettled, it overrides the wrapper. This means the auto-invalidation is effectively skipped when hooks provide their own onSettled.onSettled with specific refetch/invalidation logic, so the auto-invalidation of the mutation key rarely runs.Hook convention: place custom onSettled BEFORE ...options:
mutate(["usePostAddFlow"], fn, {
onSettled: () => { queryClient.refetchQueries(...) }, // Hook-specific invalidation
retry: false, // Override default retry if needed
...options, // Consumer options come LAST (can override onSettled, retry, etc.)
})
retry: falseOverride the default retry: 3 with retry: false for mutations where retrying would cause problems:
// Example: POST that creates a resource (not safe to retry)
const mutation = mutate(["usePostGlobalVariables"], postFn, {
onSettled: () => { queryClient.refetchQueries({ queryKey: ["useGetGlobalVariables"] }) },
retry: false,
...options,
})
For queries that need periodic refreshing (build status, messages):
export const useGetMessagesPolling: useQueryFunctionType<
{ flowId: string; sessionId: string },
Message[]
> = (params, options?) => {
const { query } = UseRequestProcessor()
const getMessagesFn = async (): Promise<Message[]> => {
const res = await api.get(
`${getURL("MESSAGES")}/?flow_id=${params.flowId}&session_id=${params.sessionId}`,
)
return res.data
}
return query(
["useGetMessagesPolling", params.flowId, params.sessionId],
getMessagesFn,
{
refetchInterval: 3000, // Poll every 3 seconds
refetchIntervalInBackground: false,
...options,
},
)
}