Scalar
Scalar handles entity fields whose values depend on a "lens" selection, such as viewing
portfolio-specific data for companies. Multiple components can render the same entity through
different lenses simultaneously, each seeing the correct values.
Data is stored in an internal Scalar entity table and joined at denormalize time based
on endpoint args. The parent entity stores lens-independent references, so switching lenses
never mutates the entity itself.
Usage
import { Entity, Scalar, schema } from '@data-client/rest';
const PortfolioScalar = new Scalar({
lens: args => args[0]?.portfolio,
key: 'portfolio',
});
class Company extends Entity {
id = '';
price = 0;
pct_equity = 0;
shares = 0;
static schema = {
pct_equity: PortfolioScalar,
shares: PortfolioScalar,
};
}
A single Scalar instance can be shared across multiple Entity classes — the
entity context is inferred at normalize time from the parent entity and recorded
on the wrapper, so denormalize always resolves to the correct cell. Compound pks
remain unique because they are namespaced by entity key (e.g.
Company|1|portfolioA vs. Fund|1|portfolioA).
Full entity endpoint
When fetching companies with a portfolio lens, scalar fields are automatically extracted
and stored in the Scalar cell table:
import { Endpoint } from '@data-client/rest';
const getCompanies = new Endpoint(
({ portfolio }: { portfolio: string }) =>
fetch(`/companies?portfolio=${portfolio}`).then(r => r.json()),
{ schema: [Company] },
);
Column-only endpoint
Fetch just the lens-dependent columns without refetching entity data. The response is a
dictionary keyed by entity pk. Because there is no parent entity in this path, the Scalar
must be bound to an Entity class via the entity option:
const CompanyPortfolioScalar = new Scalar({
lens: args => args[0]?.portfolio,
key: 'portfolio',
entity: Company,
});
const getPortfolioColumns = new Endpoint(
({ portfolio }: { portfolio: string }) =>
fetch(`/companies/columns?portfolio=${portfolio}`).then(r => r.json()),
{ schema: new schema.Values(CompanyPortfolioScalar) },
);
// Response: { '1': { pct_equity: 0.5, shares: 32342 }, '2': { ... } }
Column fetches only write Scalar(portfolio) cell entries — they never modify the Company entities.
A bound Scalar can also be used as an Entity.schema field (the binding is just ignored
in favor of the inferred parent), so a single instance can serve both endpoints.
Combined usage: portfolio grid
Both endpoints share a single Scalar instance and feed the same grid.
getCompanies loads the full row data on first render; getPortfolioColumns
fires alongside as a cheaper lens-only refresh — both write to the same
Scalar(portfolio) cell table. Switch portfolios in the dropdown to watch
% Equity and Shares swap while the Company rows themselves stay stable.
[{"id":"1","name":"Acme Corp","price":145.2,"pct_equity":0.5,"shares":10000},{"id":"2","name":"Globex","price":89.5,"pct_equity":0.2,"shares":4000},{"id":"3","name":"Initech","price":32.1,"pct_equity":0.1,"shares":2500}]
[{"id":"1","name":"Acme Corp","price":145.2,"pct_equity":0.3,"shares":6000},{"id":"2","name":"Globex","price":89.5,"pct_equity":0.4,"shares":8000},{"id":"3","name":"Initech","price":32.1,"pct_equity":0.05,"shares":1200}]
{"1":{"pct_equity":0.5,"shares":10000},"2":{"pct_equity":0.2,"shares":4000},"3":{"pct_equity":0.1,"shares":2500}}
{"1":{"pct_equity":0.3,"shares":6000},"2":{"pct_equity":0.4,"shares":8000},"3":{"pct_equity":0.05,"shares":1200}}
import { Entity, RestEndpoint, Scalar, schema } from '@data-client/rest'; export class Company extends Entity { id = ''; name = ''; price = 0; pct_equity = 0; shares = 0; } // Bound to Company so both endpoints below can use this same instance. const PortfolioScalar = new Scalar({ lens: args => args[0]?.portfolio, key: 'portfolio', entity: Company, }); Company.schema = { pct_equity: PortfolioScalar, shares: PortfolioScalar, }; export const getCompanies = new RestEndpoint({ path: '/companies', searchParams: {} as { portfolio: string }, schema: [Company], }); export const getPortfolioColumns = new RestEndpoint({ path: '/companies/columns', searchParams: {} as { portfolio: string }, schema: new schema.Values(PortfolioScalar), });
import { useSuspense, useFetch } from '@data-client/react'; import { getCompanies, getPortfolioColumns } from './api/Company'; function PortfolioGrid() { const [portfolio, setPortfolio] = React.useState('A'); // Full load: Company rows + Scalar cells for the current lens. const companies = useSuspense(getCompanies, { portfolio }); // Cheap lens-only refresh in the background — writes to the same cell table. useFetch(getPortfolioColumns, { 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> <table style={{ marginTop: 8, width: '100%' }}> <thead> <tr> <th align="left">Name</th> <th align="right">Price</th> <th align="right">% Equity</th> <th align="right">Shares</th> </tr> </thead> <tbody> {companies.map(c => ( <tr key={c.pk()}> <td>{c.name}</td> <td align="right">${c.price.toFixed(2)}</td> <td align="right">{(c.pct_equity * 100).toFixed(1)}%</td> <td align="right">{c.shares.toLocaleString()}</td> </tr> ))} </tbody> </table> </div> ); } render(<PortfolioGrid />);
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. In a real app, you'd typically reach for
getPortfolioColumns instead of getCompanies after the initial load to
avoid refetching lens-independent fields.
Constructor
new Scalar({ lens, key, entity? })
Options
lens(args: readonly any[]) => string | undefined — required. Extracts the lens value (e.g. portfolio ID) from endpoint args.keystring — required. A unique name for this scalar type, used to namespace the internalScalarentity table (e.g.'portfolio'becomesScalar(portfolio)).entityEntity class — optional. TheEntityclass thisScalarattaches to. Only required when theScalaris used standalone (e.g. insideschema.Valuesfor a column-only endpoint), where there is no parent entity to infer from. When used as a field onEntity.schema, the entity is inferred from the parent and recorded on the wrapper.
How it works
Normalize
When an entity with Scalar schema fields is normalized:
EntityMixin.normalizedetectsScalarfields and passes the whole entity object toScalar.normalize(avoiding the primitive short-circuit ingetVisit).Scalar.normalizediscovers its fields from the parent entity's schema, extracts their values, and stores them as a grouped cell viadelegate.mergeEntity(this, compoundPk, cellData)(the cell schema isthisScalar instance).- A lens-independent tuple wrapper
[entityPk, fieldName, entityKey]replaces each scalar field on the entity (an array, distinguishable from cell data viaArray.isArray).
Denormalize
The standard EntityMixin.denormalize loop is completely unchanged:
unvisit(Scalar, wrapper)callsScalar.denormalize(Scalar is not entity-like).Scalar.denormalizereads the wrapper, adds the current lens from endpointargs, and builds the compound pk.- It calls
unvisit(this, compoundPk)to look up the correct cell, then extracts and returns the specific field value.
This means different components viewing the same entity with different lens args get different scalar values, while sharing the same base entity data.
Normalized storage
entities['Company']['1'] = {
id: '1',
price: 100,
pct_equity: ['1', 'pct_equity', 'Company'],
shares: ['1', 'shares', 'Company'],
}
entities['Scalar(portfolio)']['Company|1|portfolioA'] = {
pct_equity: 0.5,
shares: 32342,
}
entities['Scalar(portfolio)']['Company|1|portfolioB'] = {
pct_equity: 0.3,
shares: 323,
}