v0.14: Streamlined Manager APIs, Performance, and GC
Managers:
Manager APIs have been streamlined—flatter action shapes reduce code complexity and improve performance by minimizing distinct inline cache entries, while simpler configuration APIs make customization more delightful.
- Simplified action shapes
- Manager.middleware property
- getDefaultManagers() configuration
Performance:
- Immediate fetch calls - no more waiting for idle
- 30% smaller bundles by removing unnecessary polyfills
- 10% faster normalization
Schema/Data Modeling:
- Query joins with Object Schemas
- EntityMixin for composing Entity behavior
- Entity.pk() default uses
id - Dynamic invalidation via Entity.process()
Testing:
- renderDataHook() for simpler testing
Garbage Collection:
- GCPolicy - automatic garbage collection of stale data
- NetworkManager fetches immediately
- action.payload -> action.response
- action.meta.args -> action.args
Managers
Manager APIs have been streamlined—flatter action shapes reduce code complexity and improve performance by minimizing distinct inline cache entries, while simpler configuration APIs make customization more delightful.
Action Shapes
Action shapes are simplified and more consistent. #3143, #3139
action.meta.args->action.argsaction.meta.key->action.keyaction.meta.nmremoved (unused)action.payload->action.response(for SET_RESPONSE)
Manager.middleware
Managers can now use a middleware property instead of getMiddleware() method. #3164
class MyManager implements Manager {
getMiddleware = (): Middleware => controller => next => async action => {
// handle action
return next(action);
};
cleanup() {}
}
class MyManager implements Manager {
middleware: Middleware = controller => next => async action => {
// handle action
return next(action);
};
cleanup() {}
}
getMiddleware() still works for backward compatibility.
actionTypes without _TYPE suffix
Cleaner action type names are now available (not breaking - we keep the old names as well). #3244
import type { Manager, Middleware } from '@data-client/react';
import { actionTypes } from '@data-client/react';
export default class LoggingManager implements Manager {
middleware: Middleware = controller => next => async action => {
switch (action.type) {
case actionTypes.SET_RESPONSE_TYPE:
console.info(
`${action.endpoint.name} ${JSON.stringify(action.response)}`,
);
default:
return next(action);
}
};
cleanup() {}
}
import type { Manager, Middleware } from '@data-client/react';
import { actionTypes } from '@data-client/react';
export default class LoggingManager implements Manager {
middleware: Middleware = controller => next => async action => {
switch (action.type) {
case actionTypes.SET_RESPONSE:
console.info(
`${action.endpoint.name} ${JSON.stringify(action.response)}`,
);
default:
return next(action);
}
};
cleanup() {}
}
getDefaultManagers()
getDefaultManagers() now accepts configuration options. #3161
import { getDefaultManagers } from '@data-client/react';
// Remove DevToolsManager
const managers = getDefaultManagers({ devToolsManager: null });
// Configure DevToolsManager
const managers = getDefaultManagers({
devToolsManager: {
latency: 1000,
predicate: (state, action) => action.type !== actionTypes.SET_RESPONSE,
},
});
// Use custom NetworkManager
const managers = getDefaultManagers({
networkManager: new IdlingNetworkManager(),
});
NetworkManager Constructor
NetworkManager constructor now uses keyword arguments for clarity.
new NetworkManager(42, 7);
new NetworkManager({ dataExpiryLength: 42, errorExpiryLength: 7 });
Performance
React 18+ concurrent mode and automatic batching change how we should optimize. Waiting for idle callbacks is no longer optimal—fetching immediately now provides better results. Internal changes leverage closures and Map data structures for faster normalization and lookup, while removing bloated polyfill dependencies shrinks bundles significantly.
Immediate Fetching
NetworkManager now fetches immediately rather than waiting for idle callbacks. #3146
This results in faster data loading, especially for applications with complex render trees.
IdlingNetworkManager remains available for those who prefer the previous behavior:
import { IdlingNetworkManager, getDefaultManagers } from '@data-client/react';
const managers = getDefaultManagers({
networkManager: new IdlingNetworkManager(),
});
Bundle Size
Packages are now 30% smaller after removing bloated polyfill packages. #3384
Additionally, polyfills no longer pollute the global scope. #3353
Normalization Performance
Internal schema interface changes provide a 10% normalization performance boost. #3134
Using Map instead of plain objects for entity storage provides up to 32% faster lookups for large datasets. #3390
Schema/Data Modeling
Schema APIs reduce boilerplate for common patterns while enabling more advanced use cases. Client-side joins now work across arbitrary entities (not just nested relationships), dynamic invalidation handles server-only deletion logic, and sensible defaults like Entity.pk() using id eliminate repetitive code.
Query Joins
Query can now take Object Schemas, enabling joins across multiple entity types. #3165
class Ticker extends Entity {
product_id = '';
price = 0;
pk() { return this.product_id; }
}
class Stats extends Entity {
product_id = '';
last = 0;
pk() { return this.product_id; }
}
// Join Ticker and Stats by product_id
const queryPrice = new schema.Query(
{ ticker: Ticker, stats: Stats },
({ ticker, stats }) => ticker?.price ?? stats?.last,
);
// Use in components
const price = useQuery(queryPrice, { product_id: 'BTC-USD' });
EntityMixin
New EntityMixin for composing Entity behavior with existing classes. #3243
import { EntityMixin } from '@data-client/rest';
// Your existing class
export class Article {
id = '';
title = '';
content = '';
tags: string[] = [];
}
// Add Entity behavior
export class ArticleEntity extends EntityMixin(Article) {}
Entity.pk() Default
Entity.pk() now defaults to this.id. #3188
class Todo extends Entity {
id = '';
title = '';
completed = false;
pk() {
return this.id;
}
}
class Todo extends Entity {
id = '';
title = '';
completed = false;
// pk() uses 'id' by default!
}
Dynamic Invalidation
Return undefined from Entity.process() to dynamically invalidate entities based on response data. #3407
class PriceLevel extends Entity {
price = 0;
amount = 0;
pk() { return `${this.price}`; }
static process(input: [number, number], parent: any, key: string | undefined) {
const [price, amount] = input;
// Invalidate when amount is 0
if (amount === 0) return undefined;
return { price, amount };
}
}
resource()
createResource() renamed to resource() for brevity. #3158
import { resource } from '@data-client/rest';
const TodoResource = resource({
path: '/todos/:id',
schema: Todo,
});
createResource remains exported for backward compatibility.
Testing
Testing should be simple. New helpers use sensible defaults to eliminate boilerplate for common test scenarios.
renderDataHook()
New renderDataHook() uses the default DataProvider, eliminating makeRenderDataHook() boilerplate. #3238
import { renderDataHook } from '@data-client/test';
const { result } = renderDataHook(
() => useSuspense(ArticleResource.get, { id: 5 }),
{
initialFixtures: [
{
endpoint: ArticleResource.get,
args: [{ id: 5 }],
response: { id: 5, title: 'Test' },
},
],
},
);
Garbage Collection
Long-running applications accumulate stale data in memory. New garbage collection policies automatically clean expired entries using reference counting to track which data is actively bound to components.
GCPolicy
GCPolicy enables automatic garbage collection of stale data. #3343
import { GCPolicy, DataProvider } from '@data-client/react';
// Run GC sweep every 10 minutes
<DataProvider gcPolicy={new GCPolicy({ intervalMS: 60 * 1000 * 10 })}>
{children}
</DataProvider>
Configure expiry behavior:
new GCPolicy({
intervalMS: 60 * 1000 * 5, // How often to run GC
expiryMultiplier: 2, // How many stale lifetimes before GC
})
Use ImmortalGCPolicy to disable garbage collection (previous default behavior):
import { ImmortalGCPolicy, DataProvider } from '@data-client/react';
<DataProvider gcPolicy={new ImmortalGCPolicy()}>
{children}
</DataProvider>
Other Improvements
- Collections work with polymorphic schemas like Union #3151
- Collections work with nested args (qs library compatibility) #3281
- Interceptors work on manager-dispatched actions #3365
- NetworkError messages include URL for better debugging
- React Native:
InteractionManager.runAfterInteractionsfor reduced frame drops #3127 - React Native:
react-nativeentry in package.json exports #3371 - Improved React 19 compatibility #3279
Migration guide
This upgrade requires updating all package versions simultaneously.
- NPM
- Yarn
- pnpm
- esm.sh
yarn add @data-client/react@^0.14.0 @data-client/rest@^0.14.0 @data-client/test@^0.14.0 @data-client/img@^0.14.0
npm install --save @data-client/react@^0.14.0 @data-client/rest@^0.14.0 @data-client/test@^0.14.0 @data-client/img@^0.14.0
pnpm add @data-client/react@^0.14.0 @data-client/rest@^0.14.0 @data-client/test@^0.14.0 @data-client/img@^0.14.0
<script type="module">
import * from 'https://esm.sh/@data-client/react@^0.14.0';
import * from 'https://esm.sh/@data-client/rest@^0.14.0';
import * from 'https://esm.sh/@data-client/test@^0.14.0';
import * from 'https://esm.sh/@data-client/img@^0.14.0';
</script>
NetworkManager fetches immediately
NetworkManager now fetches immediately rather than waiting for idle. This improves performance with React 18+. #3146
To keep the previous behavior, use IdlingNetworkManager:
import { IdlingNetworkManager, getDefaultManagers } from '@data-client/react';
const managers = getDefaultManagers({
networkManager: new IdlingNetworkManager(),
});
action.payload -> action.response
For custom Managers handling SET_RESPONSE: #3141
import {
SET_RESPONSE_TYPE,
type Manager,
type Middleware,
} from '@data-client/react';
export default class MyManager implements Manager {
getMiddleware = (): Middleware => controller => next => async action => {
switch (action.type) {
case SET_RESPONSE_TYPE:
console.log('Resolved with value', action.payload);
return next(action);
default:
return next(action);
}
};
cleanup() {}
}
import {
SET_RESPONSE_TYPE,
type Manager,
type Middleware,
} from '@data-client/react';
export default class MyManager implements Manager {
getMiddleware = (): Middleware => controller => next => async action => {
switch (action.type) {
case SET_RESPONSE_TYPE:
console.log('Resolved with value', action.response);
return next(action);
default:
return next(action);
}
};
cleanup() {}
}
fetchAction.payload removed
For custom Managers handling FETCH: #3141
import {
FETCH_TYPE,
type Manager,
type Middleware,
} from '@data-client/react';
export default class MyManager implements Manager {
getMiddleware = (): Middleware => controller => next => async action => {
switch (action.type) {
case FETCH_TYPE:
// consume fetch, and print the resolution
action.payload().then(response => console.log(response));
default:
return next(action);
}
};
cleanup() {}
}
import {
FETCH_TYPE,
type Manager,
type Middleware,
} from '@data-client/react';
export default class MyManager implements Manager {
getMiddleware = (): Middleware => controller => next => async action => {
switch (action.type) {
case FETCH_TYPE:
// consume fetch, and print the resolution
action
.endpoint(...action.args)
.then(response => console.log(response));
default:
return next(action);
}
};
cleanup() {}
}
action.meta.args -> action.args
Action arguments moved from meta to top level. #3143
const { args, key } = action.meta;
const { args, key } = action;
Schema interface changes (custom schemas only)
If you have custom schemas implementing normalize() or denormalize(), the interfaces have changed. #3134
This provides a 10% performance boost for all users.
Schema.normalize visit()
The visit() interface now removes non-contextual arguments:
/** Visits next data + schema while recurisvely normalizing */
export interface Visit {
(schema: any, value: any, parent: any, key: any, args: readonly any[]): any;
creating?: boolean;
}
processedEntity[key] = visit(
processedEntity[key],
processedEntity,
key,
this.schema[key],
addEntity,
visitedEntities,
storeEntities,
args,
);
processedEntity[key] = visit(
this.schema[key],
processedEntity[key],
processedEntity,
key,
args,
);
The information needed from these arguments are provided by closing visit() around them.
Schema.normalize interface
Changed from direct data access to using functions like getEntity:
interface SchemaSimple {
normalize(
input: any,
parent: any,
key: any,
args: any[],
visit: (
schema: any,
value: any,
parent: any,
key: any,
args: readonly any[],
) => any,
addEntity: (...args: any) => any,
getEntity: (...args: any) => any,
checkLoop: (...args: any) => any,
): any;
}
We also add checkLoop(), which moves some logic in Entity
to the core normalize algorithm.
/** Returns true if a circular reference is found */
export interface CheckLoop {
(entityKey: string, pk: string, input: object): boolean;
}
Schema.denormalize unvisit()
The unvisit argument now takes schema argument first:
interface SchemaSimple {
denormalize(
input: {},
args: readonly any[],
unvisit: (schema: any, input: any) => any,
): T;
}
Upgrade support
As usual, if you have any troubles or questions, feel free to join our or file a bug
