v0.18: Scalar, Typed File Downloads, Filter-aware Collections
v0.18 focuses on richer data modeling for values that vary by request context, simpler typed downloads, and collection matching that better reflects real API filters.
New Features:
- Scalar schema - Lens-dependent entity fields (e.g. portfolio-specific values) without ever mutating the underlying entity
- RestEndpoint
contentproperty - Typed file downloads, text responses, and streaming with a single property - resource()
nonFilterArgumentKeys- Sort/pagination args don't fragment your Collections
Other Improvements:
- Binary Content-Type auto-detection - Images, PDFs, and other binary responses are handled automatically with no configuration (#3868)
- Collection extender body types match HTTP method semantics - PATCH extenders (
.move,.remove) accept partial bodies; standalone RestEndpoint derives a typed body from the Collection's entity schema (#3910) - Export
CollectionOptionsfrom@data-client/endpointand@data-client/restfor typed Collection construction (#3904)
import { useSuspense, useFetch } from '@data-client/react'; import { getCompanies, getPortfolioColumns } from './api/Company'; import CompanyGrid from './CompanyGrid'; function PortfolioGrid() { const [portfolio, setPortfolio] = React.useState('A'); // Fetches on first render, then re-denormalizes from cache on every // portfolio switch. The Collection's `queryKey()` ignores `portfolio`, // so there is no endpoint refetch on switch. const companies = useSuspense(getCompanies, { portfolio }); // The first render's `useSuspense` already populated `Scalar(portfolio)` // for `firstPortfolio`, so we only fetch columns when the user switches // away. `useFetch` then dedupes later revisits via its endpoint cache. const firstPortfolio = React.useRef(portfolio).current; useFetch( getPortfolioColumns, portfolio === firstPortfolio ? null : { portfolio }, ); return ( <div> <label> Portfolio:{' '} <select value={portfolio} onChange={e => setPortfolio(e.currentTarget.value)} > <option value="A">Portfolio A</option> <option value="B">Portfolio B</option> </select> </label> <CompanyGrid companies={companies} /> </div> ); } render(<PortfolioGrid />);
The example shows the same Company entities rendered through different portfolio lenses. Stable Entity fields are reused, while lens-dependent values are selected from separate Scalar cells.
- Schema.denormalize() takes a delegate -
denormalize(input, args, unvisit)โdenormalize(input, delegate). Affects custom Schema implementations only. - Schema.normalize() takes a delegate -
normalize(input, parent, key, args, visit, delegate)โnormalize(input, parent, key, delegate). Affects custom Schema implementations only.
Upgrade with the automated codemod:
npx jscodeshift -t https://dataclient.io/codemods/v0.18.js --extensions=ts,tsx,js,jsx src/
Scalarโ
Scalar handles Entity fields whose values depend on a runtime "lens" โ like the selected portfolio, currency, or locale. Multiple components can render the same entity through different lenses simultaneously, each seeing the correct values, while the entity itself never changes. Lens-dependent values live in a separate cell table and are joined at denormalize time from endpoint args.
name and price references stay stable across portfolio switches because the Company entity
itself never changes โ only the Scalar cell selected by the current lens does. A single Scalar
instance can serve both as an Entity.schema field (parent entity inferred from the visit) and
standalone inside Values, [Scalar],
or Collection([Scalar]) for cheap column-only refreshes (entity bound
explicitly via entity). Cell pks are derived from the map key or via
Scalar.entityPk(), which defaults to Entity.pk() so custom and
composite primary keys work without an override.
The normalized store keeps the entity stable and the lens cells separate:
entities['Company']['1'] = {
id: '1',
price: 145.20,
pct_equity: ['1', 'pct_equity', 'Company'],
shares: ['1', 'shares', 'Company'],
}
entities['Scalar(portfolio)']['Company|1|A'] = { pct_equity: 0.50, shares: 10000 }
entities['Scalar(portfolio)']['Company|1|B'] = { pct_equity: 0.30, shares: 6000 }
The demo pairs getCompanies with a cheap getPortfolioColumns lens-only refresh writing
to the same cell table โ see the Scalar documentation for a full walkthrough
of the caching behavior.
Scalar also implements queryKey so it participates directly in
useQuery, Controller.get, and schema.Query โ
the full Queryable surface โ enumerating its cells for the current lens.
RestEndpoint content propertyโ
RestEndpoint now accepts a content property that controls
how the response body is parsed. The return type is inferred automatically from the value, and schema is
constrained to undefined at the type level for non-JSON content types.
import { RestEndpoint } from '@data-client/rest';
const downloadFile = new RestEndpoint({
path: '/files/:id/download',
content: 'blob',
dataExpiryLength: 0,
});
// Return type is Blob โ inferred from content, no explicit generics needed
const blob: Blob = await ctrl.fetch(downloadFile, { id: '123' });
Previously, file downloads required a verbose parseResponse override. Now it's a single property.
| Value | Parses via | Return type |
|---|---|---|
'json' | response.json() | any |
'blob' | response.blob() | Blob |
'text' | response.text() | string |
'arrayBuffer' | response.arrayBuffer() | ArrayBuffer |
'stream' | response.body | ReadableStream<Uint8Array> |
Setting content to a non-JSON value enforces schema: undefined at the type level, since binary data cannot be
normalized. A runtime check provides a clear error message if a normalizable schema is accidentally used.
Works with extend() and subclasses:
// Extend a JSON endpoint into a blob downloader
const download = jsonEndpoint.extend({
content: 'blob',
schema: undefined,
});
// Subclass for a reusable blob endpoint type
class BlobEndpoint<O extends RestGenerics = any> extends RestEndpoint<O> {
content = 'blob' as const;
}
resource() nonFilterArgumentKeysโ
nonFilterArgumentKeys lets resource()
declare which argument keys are not used to filter the collection results โ typically sort,
pagination, or display options. Without it, every distinct sort or page args would create a separate
Collection bucket, and newly created items wouldn't appear in lists with
different sort args even though the underlying data should match.
import { Entity, resource } from '@data-client/rest';
class Post extends Entity {
id = '';
title = '';
group = '';
author = '';
}
const PostResource = resource({
path: '/:group/posts/:id',
searchParams: {} as { orderBy?: string; author?: string },
schema: Post,
nonFilterArgumentKeys: ['orderBy'],
});
Now group and author are filter keys (they bucket separate Collections), but orderBy is
shared โ PostResource.getList.push() adds the new post to all orderBy variants of a given
{ group, author } bucket.
Accepts a string[], RegExp, or predicate function:
nonFilterArgumentKeys: /^(orderBy|page|cursor)$/,
#3914 - nonFilterArgumentKeys docs
Binary Content-Type auto-detectionโ
When content is not set, parseResponse now auto-detects binary
Content-Types and returns response.blob() instead of corrupting the data via .text().
Binary types like image/*, audio/*, video/*, font/*, application/octet-stream, application/pdf, and
others are now handled automatically. Text-like types (text/*, XML, HTML, JavaScript, CSS, etc.) continue to
use the existing text fallback with schema-aware error messages.
This means endpoints that serve binary data with correct Content-Type headers work without any configuration:
const ep = new RestEndpoint({
path: '/avatars/:id',
});
// Server responds with Content-Type: image/png
// โ automatically returns a Blob (previously corrupted via .text())
Migration guideโ
This upgrade requires updating all package versions simultaneously.
- NPM
- Yarn
- pnpm
- esm.sh
yarn add @data-client/react@^0.18.0 @data-client/rest@^0.18.0 @data-client/endpoint@^0.18.0 @data-client/core@^0.18.0 @data-client/vue@^0.18.0 @data-client/test@^0.18.0 @data-client/img@^0.18.0
npm install --save @data-client/react@^0.18.0 @data-client/rest@^0.18.0 @data-client/endpoint@^0.18.0 @data-client/core@^0.18.0 @data-client/vue@^0.18.0 @data-client/test@^0.18.0 @data-client/img@^0.18.0
pnpm add @data-client/react@^0.18.0 @data-client/rest@^0.18.0 @data-client/endpoint@^0.18.0 @data-client/core@^0.18.0 @data-client/vue@^0.18.0 @data-client/test@^0.18.0 @data-client/img@^0.18.0
<script type="module">
import * from 'https://esm.sh/@data-client/react@^0.18.0';
import * from 'https://esm.sh/@data-client/rest@^0.18.0';
import * from 'https://esm.sh/@data-client/endpoint@^0.18.0';
import * from 'https://esm.sh/@data-client/core@^0.18.0';
import * from 'https://esm.sh/@data-client/vue@^0.18.0';
import * from 'https://esm.sh/@data-client/test@^0.18.0';
import * from 'https://esm.sh/@data-client/img@^0.18.0';
</script>
The breaking changes in this release affect only custom Schema implementations.
If you only use resource() and built-in schemas (Entity,
Collection, Union, Values,
Array, Object, Query,
Invalidate, Lazy, Scalar), the upgrade is drop-in. Otherwise,
run the codemod to migrate custom schemas, then read on for the manual cases:
npx jscodeshift -t https://dataclient.io/codemods/v0.18.js --extensions=ts,tsx,js,jsx src/
Schema.denormalize() takes a delegateโ
Skip this section if you don't implement custom Schema classes.
Schema.denormalize() is now (input, delegate) instead of the previous 3-parameter
(input, args, unvisit) signature. The new
IDenormalizeDelegate exposes unvisit, args, and a new argsKey(fn) helper.
#3887
import { Entity } from '@data-client/rest';
class Wrapper {
denormalize(
input: {},
args: readonly any[],
unvisit: (s: any, v: any) => any,
) {
const value = unvisit(this.schema, input);
return this.process(value, ...args);
}
}
import { Entity, type IDenormalizeDelegate } from '@data-client/rest';
class Wrapper {
denormalize(input: {}, delegate: IDenormalizeDelegate) {
const value = delegate.unvisit(this.schema, input);
return this.process(value, ...delegate.args);
}
}
The codemod handles class methods, object methods, function declarations, TypeScript interface
signatures, and pass-through someSchema.denormalize(input, args, unvisit) calls. It also adds
the IDenormalizeDelegate import as an inline type specifier on your existing
@data-client/* import (or creates a new import type line if none is found).
args-dependent outputโ
Reading delegate.args directly does not contribute to memoization. If your schema's output
depends on those args (e.g. a lens), declare the dependency through delegate.argsKey(fn) so the
cache buckets correctly. The codemod cannot infer this โ update by hand.
argsKey returns fn(args) for convenience, and the function reference doubles as the cache path
key โ so the function must be referentially stable. Store it on the instance (constructor) or
at module scope; never pass an inline arrow, or every call allocates a fresh reference and misses
the cache.
class LensSchema {
denormalize(input, args, unvisit) {
const lens = args[0]?.portfolio;
return this.lookup(input, lens);
}
}
class LensSchema {
constructor({ lens }) {
this.lensSelector = lens; // stable: set once in the constructor
}
denormalize(input, delegate) {
const lens = delegate.argsKey(this.lensSelector);
return this.lookup(input, lens);
}
}
See Scalar for a worked example.
AI-assisted migration is also available:
- Skills
- OpenSkills
npx skills add reactive/data-client --skill data-client-v0.18-migration
npx openskills install reactive/data-client --skill data-client-v0.18-migration
Schema.normalize() takes a delegateโ
Skip this section if you don't implement custom Schema classes.
Schema.normalize() now reads endpoint args and the recursive visitor from
INormalizeDelegate, matching the new denormalize delegate shape.
The previous signature was (input, parent, key, args, visit, delegate, parentEntity?);
the new signature is (input, parent, key, delegate, parentEntity?).
#3934
import type { INormalizeDelegate, Visit } from '@data-client/rest';
class Wrapper {
normalize(
input: any,
parent: any,
key: string,
args: readonly any[],
visit: Visit,
delegate: INormalizeDelegate,
) {
const normalized = visit(this.schema, input.value, input, 'value', args);
delegate.mergeEntity(this, this.pk(input, parent, key, args), normalized);
return normalized;
}
}
import type { INormalizeDelegate } from '@data-client/rest';
class Wrapper {
normalize(
input: any,
parent: any,
key: string,
delegate: INormalizeDelegate,
) {
const { args, visit } = delegate;
const normalized = visit(this.schema, input.value, input, 'value');
delegate.mergeEntity(this, this.pk(input, parent, key, args), normalized);
return normalized;
}
}
If your schema accepts the optional parentEntity argument, keep it as the trailing argument:
normalize(input, parent, key, delegate, parentEntity) {
// ...
}
delegate.visit() already carries the same args for recursive normalization, so nested
schemas do not need args passed explicitly.
Additive: parentEntity on normalizeโ
The normalize delegate's visit() callback internally tracks the nearest enclosing
entity-like schema and forwards it to Schema.normalize() as the optional trailing
parentEntity argument. New schemas can opt in to discover their containing entity
at normalize time (used internally by Scalar).
Optional: consolidate Collection definitionsโ
Collection can now use both argsKey and
nestKey on the same instance. As an optional
cleanup, replace paired top-level and nested Collections with one shared
definition:
export const getTodos = new RestEndpoint({
path: '/todos',
searchParams: {} as { userId?: string },
schema: new Collection([Todo]),
});
class User extends Entity {
static schema = {
todos: new Collection([Todo], {
nestKey: parent => ({ userId: parent.id }),
}),
};
}
export const userTodos = new Collection([Todo], {
//argsKey: params => ({ ...params }), - this is default, so it's not needed
nestKey: parent => ({ userId: parent.id }),
});
export const getTodos = new RestEndpoint({
path: '/todos',
searchParams: {} as { userId?: string },
schema: userTodos,
});
class User extends Entity {
static schema = {
todos: userTodos,
};
}
Upgrade supportโ
As usual, if you have any troubles or questions, feel free to join our or file a bug
