Data Connectivity
ObjectUI follows the Universal Adapter Pattern. UI components do not hardcode transport details. They receive a DataSource implementation from SchemaRendererProvider and call a stable CRUD/query contract.
This keeps the renderer backend-agnostic: ObjectStack, REST, GraphQL, and proprietary backends can all be adapted behind the same interface.
The Interface
The canonical interface lives in @object-ui/types:
import type { QueryParams, QueryResult } from '@object-ui/types';
export interface DataSource<T = unknown> {
find(resource: string, params?: QueryParams): Promise<QueryResult<T>>;
findOne(resource: string, id: string | number, params?: QueryParams): Promise<T | null>;
create(resource: string, data: Partial<T>): Promise<T>;
update(
resource: string,
id: string | number,
data: Partial<T>,
opts?: { ifMatch?: string },
): Promise<T>;
delete(
resource: string,
id: string | number,
opts?: { ifMatch?: string },
): Promise<boolean>;
// Optional: atomically persist an ordered set of cross-object operations
// (master-detail save). `{ $ref: <op index> }` links a child to a parent
// created earlier in the same batch. Adapters without server-side atomicity
// may emulate it — see below.
batchTransaction?(
operations: BatchTransactionOperation[],
): Promise<{ results: any[] }>;
getObjectSchema(objectName: string): Promise<unknown>;
}find() returns a QueryResult<T> so components can receive both rows and pagination metadata:
interface QueryResult<T = unknown> {
data: T[];
total?: number;
page?: number;
pageSize?: number;
hasMore?: boolean;
cursor?: string;
metadata?: Record<string, unknown>;
}Available Adapters
ObjectStack Adapter (Official)
Use @object-ui/data-objectstack for ObjectStack-compatible backends.
pnpm add @object-ui/data-objectstackimport { createObjectStackAdapter } from '@object-ui/data-objectstack';
const dataSource = createObjectStackAdapter({
baseUrl: 'https://api.your-instance.com'
});Usage
Inject the data source at the renderer boundary:
import '@object-ui/components';
import '@object-ui/fields';
import { SchemaRenderer, SchemaRendererProvider } from '@object-ui/react';
import { createObjectStackAdapter } from '@object-ui/data-objectstack';
const dataSource = createObjectStackAdapter({
baseUrl: 'https://api.example.com'
});
function App() {
return (
<SchemaRendererProvider dataSource={dataSource}>
<SchemaRenderer schema={mySchema} />
</SchemaRendererProvider>
);
}Creating a Custom Adapter
If you have a proprietary backend, wrap its SDK or client in a DataSource implementation. Keep transport details in the adapter, not in renderers.
import type { DataSource, QueryParams, QueryResult } from '@object-ui/types';
type User = {
id: string;
name: string;
email: string;
};
type BackendClient = {
listUsers(params?: QueryParams): Promise<{ rows: User[]; total?: number }>;
getUser(id: string | number): Promise<User | null>;
createUser(data: Partial<User>): Promise<User>;
updateUser(id: string | number, data: Partial<User>): Promise<User>;
deleteUser(id: string | number): Promise<boolean>;
describeObject(name: string): Promise<unknown>;
};
class UserDataSource implements DataSource<User> {
constructor(private readonly client: BackendClient) {}
async find(resource: string, params?: QueryParams): Promise<QueryResult<User>> {
if (resource !== 'users') {
return { data: [], total: 0 };
}
const result = await this.client.listUsers(params);
return {
data: result.rows,
total: result.total,
};
}
findOne(_resource: string, id: string | number): Promise<User | null> {
return this.client.getUser(id);
}
create(_resource: string, data: Partial<User>): Promise<User> {
return this.client.createUser(data);
}
update(_resource: string, id: string | number, data: Partial<User>): Promise<User> {
return this.client.updateUser(id, data);
}
delete(_resource: string, id: string | number): Promise<boolean> {
return this.client.deleteUser(id);
}
getObjectSchema(objectName: string): Promise<unknown> {
return this.client.describeObject(objectName);
}
}Query Parameters
ObjectUI uses OData-style query keys for broad compatibility:
await dataSource.find('users', {
$select: ['id', 'name', 'email'],
$filter: { status: 'active' },
$orderby: { name: 'asc' },
$skip: 0,
$top: 25,
$count: true,
});Data-aware plugins may also use optional methods such as batchTransaction, bulkUpdate, bulkDelete, getView, or listViewOverrides when an adapter supports them. Keep the required CRUD methods implemented first, then add optional capabilities as your UI needs them.
Cross-object atomic writes (batchTransaction)
Master-detail saves (a parent record plus its child line items) go through
dataSource.batchTransaction(operations) — one ordered list of cross-object
create/update/delete operations, where a child's foreign key can be
{ $ref: <parent op index> } to point at a parent created in the same batch.
The @object-ui/data-objectstack adapter maps this to the published
@objectstack/client data.batchTransaction SDK method, which drives the
server's atomic POST /api/v1/batch endpoint (commit-all-or-roll-back-all).
Adapters without a
transactional endpoint don't need to hand-write orchestration: call
emulateBatchTransaction(dataSource, operations) from @object-ui/core, which
executes the operations sequentially (resolving $refs) with best-effort
compensation on failure. UI components never branch on atomicity — they call
runBatchTransaction(dataSource, operations) (also from @object-ui/core),
which uses the adapter's method when present and emulates otherwise.
The @object-ui/data-objectstack adapter decides whether it can trust server
atomicity declaratively, at connect time: it reads the
capabilities.transactionalBatch flag from GET /api/v1/discovery
(framework #3298). When the backend advertises true, the adapter treats any
/batch failure as a real error — no non-atomic client-side compensation. When
the flag is false or absent (a backend predating #3298), it keeps the legacy
behaviour: probe /batch and fall back to the non-atomic emulation on
404/405/501. Atomic cross-object saves are therefore guaranteed only
against backends that advertise the capability; older ones still save, but
best-effort. See the
adapter README
for the full capability table and minimum-backend note.