skills/pimcore-studio-ui-rtk-query-fundamentals/SKILL.md
RTK Query basics for data fetching in Pimcore Studio - queries, mutations, caching, error handling with trackError
npx skillsauth add pimcore/skills pimcore-studio-ui-rtk-query-fundamentalsInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Fundamental patterns for using RTK Query (Redux Toolkit Query) in Pimcore Studio:
trackErrorUse this when:
BEFORE writing any import, read CRITICAL-IMPORT-PATHS.md.
All examples below use bundle imports (@pimcore/studio-ui-bundle/*). For core development (@sdk/*, @Pimcore/*), see the referenced file.
RTK Query is Redux Toolkit's data fetching layer:
In Pimcore Studio: API hooks are auto-generated from OpenAPI specifications, ensuring type safety and backend/frontend consistency.
Use queries for: GET requests, fetching data
import { useAssetGetByIdQuery } from '@pimcore/studio-ui-bundle/api/asset'
const MyComponent = ({ assetId }: { assetId: number }) => {
const { data, isLoading, error, refetch } = useAssetGetByIdQuery({
id: assetId
})
// data: response from API
// isLoading: true during initial fetch
// error: contains error if request failed
// refetch: function to manually refetch
}
Use mutations for: POST, PUT, PATCH, DELETE requests
import { useAssetUpdateMutation } from '@pimcore/studio-ui-bundle/api/asset'
const MyComponent = () => {
const [updateAsset, { isLoading, error }] = useAssetUpdateMutation()
const handleSave = () => {
updateAsset({
id: 123,
body: { filename: 'new-name.jpg' }
})
// Don't use .unwrap() - handle errors via error state
}
}
Key Difference:
DON'T use .unwrap() and try/catch blocks! Instead, handle errors via the error state with trackError in useEffect.
import { useAssetGetByIdQuery } from '@pimcore/studio-ui-bundle/api/asset'
import { trackError, ApiError } from '@pimcore/studio-ui-bundle/modules/app'
import { useEffect } from 'react'
import { isNil } from 'lodash'
const AssetDetail = ({ assetId }: { assetId: number }) => {
const { data, isLoading, error } = useAssetGetByIdQuery({ id: assetId })
// Track errors at component top level
useEffect(() => {
if (!isNil(error)) {
trackError(new ApiError(error))
}
}, [error])
if (isLoading) return <Skeleton />
if (!data) return null
return <div>{data.filename}</div>
}
import { useAssetUpdateMutation } from '@pimcore/studio-ui-bundle/api/asset'
import { trackError, ApiError } from '@pimcore/studio-ui-bundle/modules/app'
import { useEffect } from 'react'
import { isNil } from 'lodash'
const AssetEditor = ({ asset }: { asset: Asset }) => {
const [updateAsset, { isLoading, error }] = useAssetUpdateMutation()
// Track mutation errors at component top level
useEffect(() => {
if (!isNil(error)) {
trackError(new ApiError(error))
}
}, [error])
const handleSave = (values: FormValues) => {
// No try/catch needed - errors tracked via useEffect
updateAsset({
id: asset.id,
body: values
})
}
return (
<Form onFinish={handleSave}>
{/* fields */}
<Button htmlType="submit" loading={isLoading}>
Save
</Button>
</Form>
)
}
When you have multiple mutations, track each error separately:
import { isNil } from 'lodash'
const MyComponent = () => {
const [createMutation, { error: createError }] = useCreateMutation()
const [updateMutation, { error: updateError }] = useUpdateMutation()
const [deleteMutation, { error: deleteError }] = useDeleteMutation()
// Track each error separately
useEffect(() => {
if (!isNil(createError)) {
trackError(new ApiError(createError))
}
}, [createError])
useEffect(() => {
if (!isNil(updateError)) {
trackError(new ApiError(updateError))
}
}, [updateError])
useEffect(() => {
if (!isNil(deleteError)) {
trackError(new ApiError(deleteError))
}
}, [deleteError])
}
Instead of try/catch, check the error state:
import { isNil } from 'lodash'
const MyForm = () => {
const [createEntity, { data, error, isLoading }] = useCreateEntityMutation()
useEffect(() => {
if (!isNil(error)) {
trackError(new ApiError(error))
}
}, [error])
// Check for success
useEffect(() => {
if (!isNil(data)) {
// Success! Navigate, close dialog, show message, etc.
message.success('Created successfully')
}
}, [data])
const handleSubmit = (values: FormValues) => {
createEntity({ body: values })
}
}
trackError automatically:
You don't need to manually show error messages - trackError handles it!
import { useDataObjectGetByIdQuery } from '@pimcore/studio-ui-bundle/api/data-object'
import { trackError, ApiError } from '@pimcore/studio-ui-bundle/modules/app'
import { isNil } from 'lodash'
const DataObjectDetail = ({ id }: { id: number }) => {
const { data, isLoading, error } = useDataObjectGetByIdQuery({ id })
useEffect(() => {
if (!isNil(error)) {
trackError(new ApiError(error))
}
}, [error])
if (isLoading) return <Spin />
if (!data) return null
return <div>{data.key}</div>
}
Don't fetch until condition is met:
import { isNil } from 'lodash'
const DetailPanel = ({ selectedId }: { selectedId: number | null }) => {
const { data, error } = useAssetGetByIdQuery(
{ id: selectedId! },
{ skip: selectedId === null } // Don't fetch if no selection
)
useEffect(() => {
if (!isNil(error)) {
trackError(new ApiError(error))
}
}, [error])
if (!selectedId) return <div>Select an asset</div>
if (!data) return <Spin />
return <div>{data.filename}</div>
}
const AssetView = ({ id }: { id: number }) => {
const { data, isFetching, refetch } = useAssetGetByIdQuery({ id })
return (
<div>
<Button
onClick={() => void refetch()}
loading={isFetching}
>
Refresh
</Button>
<div>{data?.filename}</div>
</div>
)
}
import { useDataObjectCreateMutation } from '@pimcore/studio-ui-bundle/api/data-object'
import { trackError, ApiError } from '@pimcore/studio-ui-bundle/modules/app'
import { isNil } from 'lodash'
const CreateDialog = () => {
const [create, { data, error, isLoading }] = useDataObjectCreateMutation()
useEffect(() => {
if (!isNil(error)) {
trackError(new ApiError(error))
}
}, [error])
useEffect(() => {
if (!isNil(data)) {
message.success('Created successfully')
// Close dialog, navigate, etc.
}
}, [data])
const handleCreate = (values: FormValues) => {
create({
body: {
parentId: values.parentId,
key: values.key,
className: values.className
}
})
}
return (
<FormKit type="form" onSubmit={handleCreate}>
{/* form fields */}
<FormKit type="submit" loading={isLoading}>
Create
</FormKit>
</FormKit>
)
}
import { useAssetUpdateMutation } from '@pimcore/studio-ui-bundle/api/asset'
import { trackError, ApiError } from '@pimcore/studio-ui-bundle/modules/app'
import { isNil } from 'lodash'
const AssetEditor = ({ asset }: { asset: Asset }) => {
const [updateAsset, { data, error, isLoading }] = useAssetUpdateMutation()
useEffect(() => {
if (!isNil(error)) {
trackError(new ApiError(error))
}
}, [error])
useEffect(() => {
if (!isNil(data)) {
message.success('Saved successfully')
}
}, [data])
const handleSave = (values: FormValues) => {
updateAsset({
id: asset.id,
body: values
})
}
return (
<FormKit type="form" onSubmit={handleSave}>
{/* fields */}
<FormKit type="submit" loading={isLoading}>
Save
</FormKit>
</FormKit>
)
}
import { useAssetDeleteMutation } from '@pimcore/studio-ui-bundle/api/asset'
import { trackError, ApiError } from '@pimcore/studio-ui-bundle/modules/app'
import { isNil } from 'lodash'
const DeleteButton = ({ assetId }: { assetId: number }) => {
const [deleteAsset, { data, error, isLoading }] = useAssetDeleteMutation()
useEffect(() => {
if (!isNil(error)) {
trackError(new ApiError(error))
}
}, [error])
useEffect(() => {
if (!isNil(data)) {
message.success('Deleted successfully')
// Navigate away, close dialog, etc.
}
}, [data])
const handleDelete = () => {
deleteAsset({ id: assetId })
}
return (
<Button
danger
onClick={handleDelete}
loading={isLoading}
>
Delete
</Button>
)
}
const { data, isLoading, isFetching } = useAssetGetByIdQuery({ id })
isLoading: true only on first fetch (no cached data exists)isFetching: true on any fetch (including refetch with cached data)Use cases:
isLoading: Show skeleton/spinner for initial loadisFetching: Show refresh indicator when refetching// Initial load
if (isLoading) {
return <Skeleton active />
}
// Refetch indicator
return (
<div>
{isFetching && <Spin />}
<Button onClick={() => void refetch()}>
Refresh
</Button>
<Content data={data} />
</div>
)
const [updateAsset, { isLoading: isSaving }] = useAssetUpdateMutation()
return (
<FormKit type="form" onSubmit={handleSave}>
{/* form fields */}
<FormKit
type="submit"
loading={isSaving}
disabled={isSaving}
>
{isSaving ? 'Saving...' : 'Save'}
</FormKit>
</FormKit>
)
RTK Query caches responses automatically:
// First component
const ComponentA = () => {
const { data } = useAssetGetByIdQuery({ id: 123 })
// Fetches from server
}
// Second component (same query)
const ComponentB = () => {
const { data } = useAssetGetByIdQuery({ id: 123 })
// Uses cached data, no network request!
}
Cache is automatically invalidated when:
Force refetch even with cached data:
const { refetch } = useAssetGetByIdQuery({ id: 123 })
// Refetch from server
refetch()
import { isNil } from 'lodash'
const AssetBrowser = () => {
const [selectedId, setSelectedId] = useState<number | null>(null)
// List query (always active)
const { data: list, error: listError } = useAssetListQuery()
// Detail query (only when selected)
const { data: detail, error: detailError } = useAssetGetByIdQuery(
{ id: selectedId! },
{ skip: !selectedId }
)
// Track errors
useEffect(() => {
if (!isNil(listError)) {
trackError(new ApiError(listError))
}
}, [listError])
useEffect(() => {
if (!isNil(detailError)) {
trackError(new ApiError(detailError))
}
}, [detailError])
return (
<div>
<AssetList
assets={list}
onSelect={setSelectedId}
/>
{detail && <AssetDetail asset={detail} />}
</div>
)
}
Load data based on previous query result:
import { isNil } from 'lodash'
const AssetWithParent = ({ id }: { id: number }) => {
// First query
const { data: asset, error: assetError } = useAssetGetByIdQuery({ id })
// Second query (depends on first)
const { data: parent, error: parentError } = useAssetGetByIdQuery(
{ id: asset?.parentId! },
{ skip: !asset?.parentId } // Don't fetch until we have parentId
)
useEffect(() => {
if (!isNil(assetError)) {
trackError(new ApiError(assetError))
}
}, [assetError])
useEffect(() => {
if (!isNil(parentError)) {
trackError(new ApiError(parentError))
}
}, [parentError])
return (
<div>
<div>Asset: {asset?.filename}</div>
{parent && <div>Parent: {parent.filename}</div>}
</div>
)
}
Automatically refetch at intervals:
const { data } = useAssetGetByIdQuery(
{ id: 123 },
{
pollingInterval: 5000 // Refetch every 5 seconds
}
)
Show UI change immediately, revert if mutation fails:
const ToggleButton = ({ asset }: { asset: Asset }) => {
const [localState, setLocalState] = useState(asset.published)
const [updateAsset, { error }] = useAssetUpdateMutation()
useEffect(() => {
if (error !== undefined) {
// Revert on error
setLocalState(asset.published)
trackError(new ApiError(error))
}
}, [error, asset.published])
const handleToggle = () => {
const newState = !localState
setLocalState(newState) // Optimistic update
updateAsset({
id: asset.id,
body: { published: newState }
})
}
return (
<Switch checked={localState} onChange={handleToggle} />
)
}
Pimcore Studio follows a consistent naming pattern:
use{Entity}{Action}{Query|Mutation}
Examples:
useAssetGetByIdQuery - Get single assetuseAssetListQuery - Get asset listuseAssetCreateMutation - Create assetuseAssetUpdateMutation - Update assetuseAssetDeleteMutation - Delete assetuseDataObjectGetByIdQuery - Get data objectuseDataObjectUpdateMutation - Update data objectImport pattern:
import {
useAssetGetByIdQuery,
useAssetUpdateMutation
} from '@pimcore/studio-ui-bundle/api/asset'
Hooks are fully typed:
// Query hook returns typed response
const { data } = useAssetGetByIdQuery({ id: 123 })
// data: Asset | undefined
// Mutation accepts typed parameters
const [update] = useAssetUpdateMutation()
update({
id: number,
body: AssetUpdateBody
})
Types are auto-generated from OpenAPI spec - always up to date with backend.
❌ Don't use .unwrap() and try/catch
// BAD - Don't do this!
try {
await updateAsset({ id, body }).unwrap()
message.success('Saved')
} catch (error) {
message.error('Failed')
}
✅ Use error state with trackError
// GOOD
const [updateAsset, { data, error }] = useAssetUpdateMutation()
useEffect(() => {
if (error !== undefined) {
trackError(new ApiError(error))
}
}, [error])
useEffect(() => {
if (data !== undefined) {
message.success('Saved')
}
}, [data])
const handleSave = (values: FormValues) => {
updateAsset({ id, body: values })
}
return (
<FormKit type="form" onSubmit={handleSave}>
{/* form fields */}
</FormKit>
)
❌ Don't forget to handle loading state
// BAD
const { data } = useAssetGetByIdQuery({ id })
return <div>{data.filename}</div> // Error if data undefined!
✅ Always check data exists
// GOOD
const { data, isLoading } = useAssetGetByIdQuery({ id })
if (isLoading) return <Spin />
if (!data) return null
return <div>{data.filename}</div>
❌ Don't call mutation in render
// BAD - causes infinite loop
const [update] = useAssetUpdateMutation()
update({ id, body }) // Called every render!
✅ Call mutation in event handler
// GOOD
const [update] = useAssetUpdateMutation()
const handleSave = (values: FormValues) => update({ id, body: values })
return (
<FormKit type="form" onSubmit={handleSave}>
{/* form fields */}
<FormKit type="submit">Save</FormKit>
</FormKit>
)
❌ Don't manually show error messages
// BAD - trackError already shows the error!
useEffect(() => {
if (error !== undefined) {
trackError(new ApiError(error))
message.error('Failed') // Duplicate error display!
}
}, [error])
✅ Trust trackError to handle error display
// GOOD - trackError shows error modal automatically
useEffect(() => {
if (error !== undefined) {
trackError(new ApiError(error))
}
}, [error])
skip option - Don't fetch until needed// ❌ WRONG - Don't use .unwrap()
const [updateAsset] = useAssetUpdateMutation()
const handleSave = async () => {
try {
await updateAsset({ id: 123, body: data }).unwrap()
} catch (error) {
console.error(error)
}
}
// ✅ CORRECT - Use error state with trackError
const [updateAsset, { error }] = useAssetUpdateMutation()
useEffect(() => {
if (error !== undefined) {
trackError(new ApiError(error))
}
}, [error])
// ❌ WRONG - Falsy check
const { data } = useAssetGetByIdQuery({ id })
if (!data) return null
// ✅ CORRECT - Use isNil
import { isNil } from 'lodash'
const { data } = useAssetGetByIdQuery({ id })
if (isNil(data)) return null
// ❌ WRONG - trackError already shows error modal!
useEffect(() => {
if (error !== undefined) {
trackError(new ApiError(error))
message.error('Failed to load') // Duplicate!
}
}, [error])
// ✅ CORRECT - trackError handles display
useEffect(() => {
if (error !== undefined) {
trackError(new ApiError(error))
}
}, [error])
// ❌ WRONG - Rendering before data loaded
const { data } = useAssetGetByIdQuery({ id })
return <div>{data.name}</div> // Crashes if data is undefined!
// ✅ CORRECT - Check loading and nil
import { isNil } from 'lodash'
const { data, isLoading } = useAssetGetByIdQuery({ id })
if (isLoading) return <Skeleton />
if (isNil(data)) return null
return <div>{data.name}</div>
// ❌ WRONG - In bundle, using @sdk
import { useAssetGetByIdQuery } from '@sdk/api/asset'
// ✅ CORRECT - Use bundle imports
import { useAssetGetByIdQuery } from '@pimcore/studio-ui-bundle/api/asset'
import { trackError, ApiError } from '@pimcore/studio-ui-bundle/modules/app'
tools
UX and UI design conventions for Pimcore Studio - layout, spacing, action labels, writing style, and design principles for consistent extensions
tools
Widget system in Pimcore Studio UI - registering widgets, opening them in layout areas, WidgetManagerTabConfig, and connecting widgets to navigation
tools
How bundles consume the Pimcore Studio UI SDK - plugins, modules, DI, registries, and imports
development
TypeScript coding standards and best practices for Pimcore Studio UI - type safety, null checks, and code quality