Data Providers
The single adapter every component fetches and mutates through.
A data provider is the one object that stands between Chelone and your API. Every list, field, form, and mutation hook speaks to it — never directly to the network — so swapping REST for GraphQL for Supabase is a one-line change at the root. The provider is just an object with nine async methods.
The contract
The types below are the ones in @chelone/core, trimmed to the shape. Every
method is generic in the resource name, so a typed registry checks resource
and types the records in the result.
type Pagination =
| { page: number; perPage: number }
| { cursor: string | null; limit: number };
type SortPayload = { field: string; order: "ASC" | "DESC" };
interface PageInfo {
nextCursor?: string | null;
prevCursor?: string | null;
hasNextPage?: boolean;
hasPreviousPage?: boolean;
}
interface GetListParams {
pagination?: Pagination;
sort?: SortPayload | SortPayload[]; // an array sorts by each key in order
filter?: Record<string, unknown>;
meta?: unknown;
}
interface GetListResult {
data: Record[];
total?: number; // optional
pageInfo?: PageInfo;
}
interface DataProvider {
getList(resource, params: GetListParams): Promise<GetListResult>;
getOne(resource, params: { id; meta? }): Promise<{ data }>;
getMany(resource, params: { ids; meta? }): Promise<{ data: Record[] }>;
getManyReference(
resource,
params: {
target: string; // the foreign-key field on `resource`
id: Identifier; // the value `target` must equal
pagination?: Pagination;
sort?: SortPayload | SortPayload[];
filter?: Record<string, unknown>;
meta?: unknown;
},
): Promise<GetListResult>;
create(resource, params: { data; meta? }): Promise<{ data }>;
update(resource, params: { id; data; previousData?; meta? }): Promise<{ data }>;
updateMany(resource, params: { ids; data; meta? }): Promise<{ data: Identifier[] }>;
delete(resource, params: { id; previousData?; meta? }): Promise<{ data }>;
deleteMany(resource, params: { ids; meta? }): Promise<{ data: Identifier[] }>;
}paginationis{ page, perPage }for an offset backend or{ cursor, limit }for a keyset one. The list views anduseInfiniteGetListalways page by number, so a provider behind them has to accept{ page, perPage }. A cursor reaches the provider only from your owngetListcall.sortis one{ field, order }or an array of them, the first being the primary key.metais yours: Chelone does not read it, so it can carry anything the backend needs that has no param of its own.updateManyanddeleteManyresolve with the ids they touched, not records.getOne,create,updateanddeleteresolve one record indata;getList,getManyandgetManyReferenceresolve an array.
Optional total
total is the count of every match, not of the page. A backend that cannot
count, as a cursor API often cannot, leaves it out. Pagination then adapts:
ListPaginationshows the range (1-25) withoutof N.- Whether a next page exists comes from
pageInfo.hasNextPageandpageInfo.hasPreviousPagewhen the result carriespageInfo. With neitherpageInfonortotal, the list controller cannot tell, andhasNextPagestaysundefined. useInfiniteGetListasks for the next page whilepageInfo.hasNextPageis true. With nopageInfoit compares the rows loaded so far tototal, and with nototaleither it stops at the first page that comes back shorter thanperPageor empty.ReferenceManyCountreadstotaland renders nothing when it is missing.
The in-memory provider
fakeDataProvider implements the full contract over an in-memory seed — no
server. It's how the demo and every live preview on this site run, and it's the
fastest way to prototype:
import { fakeDataProvider } from "@chelone/core";
const dataProvider = fakeDataProvider({
posts: [{ id: 1, title: "Hello", author_id: 1 }],
authors: [{ id: 1, name: "Ada Lovelace" }],
});It copies the seed, so the arrays you pass in are never touched, and it
mutates its own copy: a record you create, update, or delete stays changed for
the life of the provider. Filtering is exact equality on each key (===) and
sorting accepts one key or an array. A getList without pagination returns
every row. A { page, perPage } call returns total; a { cursor, limit }
call also returns pageInfo, with the cursor as a stringified offset. A full
CRUD flow works with zero backend.
Writing your own
Any object that satisfies the interface is a valid provider. fetchUtils is a
thin layer over fetch for the REST case: fetchJson sends the request and
parses the body, and queryParameters builds the query string.
import { fetchUtils } from "@chelone/core";
import type { DataProvider } from "@chelone/core";
const { fetchJson, queryParameters } = fetchUtils;
// Each verb is generic in the resource name, so TypeScript cannot match a
// parsed body to the record type: `as never` hands that check to your backend.
export const dataProvider: DataProvider = {
async getList(resource, { pagination, sort, filter }) {
const query = queryParameters({ ...pagination, sort, filter });
const { json, headers } = await fetchJson(`/api/${resource}?${query}`);
if (!Array.isArray(json)) throw new Error(`Expected a list from ${resource}`);
// `total` is optional: leave it out when the backend sends no count.
const count = headers.get("X-Total");
return {
data: json as never,
total: count === null ? undefined : Number(count),
};
},
async getOne(resource, { id }) {
const { json } = await fetchJson(`/api/${resource}/${id}`);
return { data: json as never };
},
// …create / update / delete / getMany / …
};fetchJson(url, options) takes the options of fetch plus a token. It sets
these headers unless you already did:
Accept: application/json, always.Content-Type: application/json, whenbodyis a string.
A non-empty token is sent as Authorization: Bearer <token> and wins over an
Authorization header in options.headers; an empty token is ignored. It
resolves { status, statusText, headers, body, json } on a 2xx, where body is
the response text and json is the parsed body, or undefined when the text is
not JSON. queryParameters skips
undefined and null, repeats the key for an array, writes a Date as an ISO
string, and JSON-encodes any other object.
HttpError
fetchJson rejects a non-2xx response with an HttpError. It carries the
status and the parsed response body, so the auth provider can tell an expired
session from any other failure. Rejecting from checkError logs the user out, so
reject on a 401 or 403:
import { HttpError } from "@chelone/core";
import type { AuthProvider } from "@chelone/core";
export const authProvider: Pick<AuthProvider, "checkError"> = {
async checkError(error) {
if (
error instanceof HttpError &&
(error.status === 401 || error.status === 403)
) {
throw { message: "chelone.notification.logged_out" };
}
},
};The rejection value is read for hints, so it does not have to be the original
error. Any object works, and so does a fresh Error, but an Error always
carries a message, so its text becomes the notification. To get the default
notice, reject with an object that has no message key. These keys are read:
redirectTo: where to send the user. Without it, a logout goes to the login page. A value starting withhttpsuppresses the notification.logoutUser:falsekeeps the session and shows a "not authorized" notice, and still redirects whenredirectTois set. It defaults totrue.message: the notification text or a translation key.falseshows no notification. With nomessagekey, the notice ischelone.notification.logged_outon a logout andchelone.notification.not_authorizedotherwise.
throw { redirectTo: "/sign-in", message: "Your session timed out" };
throw { logoutUser: false, message: false };
throw new Error("Unauthorized"); // notifies "Unauthorized", not the defaultResolving from checkError leaves an ordinary data error alone.
A network failure is not an HttpError: fetchJson rejects it with the error
fetch threw.
Server-side validation
Reject a create or update with a ValidationError and the form shows the
messages next to the fields instead of a generic error notification. The
constructor takes a message and a fieldErrors object that maps a field's
source path to one message or an array of them.
import { fetchUtils, HttpError, ValidationError } from "@chelone/core";
import type { DataProvider } from "@chelone/core";
const { fetchJson } = fetchUtils;
export const dataProvider: Pick<DataProvider, "create"> = {
async create(resource, { data }) {
try {
const { json } = await fetchJson(`/api/${resource}`, {
method: "POST",
body: JSON.stringify(data),
});
return { data: json as never };
} catch (error) {
if (error instanceof HttpError && error.status === 422) {
const { errors } = error.body as {
errors: Record<string, string | string[]>;
};
throw new ValidationError("Validation failed", errors);
}
throw error;
}
},
};The record controllers resolve save with the fieldErrors on a
ValidationError and raise no error notification. Any other rejection
notifies.
Reverse relations
getManyReference fetches the records of resource whose target field equals
id: the "many" side of a one-to-many. target is the foreign key on the
records you fetch, so getManyReference("posts", { target: "author_id", id: 1 })
returns the posts of author 1. It takes the same pagination, sort, filter
and meta as getList and returns the same { data, total?, pageInfo? }.
async getManyReference(resource, { target, id, pagination, sort, filter }) {
const query = queryParameters({
...pagination,
sort,
filter: { ...filter, [target]: id },
});
const { json, headers } = await fetchJson(`/api/${resource}?${query}`);
const count = headers.get("X-Total");
return {
data: json as never,
total: count === null ? undefined : Number(count),
};
},Three fields read through it:
ReferenceManyFieldshows the matching rows, one server page at a time, withsortandfilterforwarded.ReferenceManyCountshows thetotalof a one-row page, so a cell can show a count while the provider sends a single row.ReferenceOneFieldtakes the first match, for a one-to-one relation.
All three take reference (the resource) and target (the foreign key), plus
an optional source, the field on the current record that target matches. It
defaults to id. Outside a field, useGetManyReference makes the same call.
Combining providers
When resources live in different backends, combineDataProviders takes a
matcher that picks the provider for each resource. The matcher runs on every
call, and a resource it returns nothing for rejects with an error.
import { combineDataProviders } from "@chelone/core";
import { catalogProvider } from "./catalogProvider";
import { billingProvider } from "./billingProvider";
export const dataProvider = combineDataProviders((resource) => {
switch (resource) {
case "invoices":
case "payments":
return billingProvider;
default:
return catalogProvider;
}
});Lifecycle callbacks
withLifecycleCallbacks wraps a provider with before and after hooks for
any of the nine verbs, such as beforeCreate or afterGetOne. Each handler
names a resource, or "*" for every resource. Handlers run in array order,
the before chain feeds the provider, and the after chain feeds the caller.
Every callback must return the value, since undefined does not mean unchanged.
Each callback also receives the wrapped provider as its last argument; calling it
does not re-enter the callbacks.
import { withLifecycleCallbacks } from "@chelone/core";
import { baseProvider } from "./baseProvider";
export const dataProvider = withLifecycleCallbacks(baseProvider, [
{
resource: "posts",
beforeCreate: (params) => ({
...params,
data: { ...params.data, createdAt: new Date().toISOString() },
}),
afterGetOne: (result) => ({
...result,
data: {
...result.data,
title:
typeof result.data.title === "string"
? result.data.title.trim()
: result.data.title,
},
}),
},
]);Using the provider
You rarely call the provider by hand — the views and the mutation hooks do it —
but useDataProvider returns it when you need a one-off call:
import { useDataProvider } from "@chelone/react";
const dataProvider = useDataProvider();
await dataProvider.getOne("posts", { id: 1 });Tips
- Return
{ data, total }fromgetListwhen the backend can count:totaldrives the page numbers. Without it, sendpageInfoso the next and previous buttons still work. - Errors are thrown/rejected promises; the query cache surfaces them to the
view, and
checkErroron the auth provider can react to 401/403. - Reference fields share one
getManyper resource: they read throughuseGetManyAggregate, which batches reads started in the same tick into a single call, so a list of fifty posts asks for its authors once. useGetManyReference(resource, params)readsgetManyReferenceanduseInfiniteGetList(resource, params)readsgetListpage by page, withfetchNextPageappending the next one. Itsparams.paginationmust be{ page, perPage }.