Skip to main content

v0.14: Streamlined Manager APIs, Performance, and GC

· 11 min read
Nathaniel Tucker
Creator of Reactive Data Client

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.

Performance:

Schema/Data Modeling:

Testing:

Garbage Collection:

  • GCPolicy - automatic garbage collection of stale data

Breaking Changes:

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.args
  • action.meta.key -> action.key
  • action.meta.nm removed (unused)
  • action.payload -> action.response (for SET_RESPONSE)

Manager.middleware

Managers can now use a middleware property instead of getMiddleware() method. #3164

Before
class MyManager implements Manager {
getMiddleware = (): Middleware => controller => next => async action => {
// handle action
return next(action);
};

cleanup() {}
}
After
class MyManager implements Manager {
middleware: Middleware = controller => next => async action => {
// handle action
return next(action);
};

cleanup() {}
}
tip

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

Before
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() {}
}
After
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.

Before
new NetworkManager(42, 7);
After
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

Before
class Todo extends Entity {
id = '';
title = '';
completed = false;

pk() {
return this.id;
}
}
After
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,
});
note

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.runAfterInteractions for reduced frame drops #3127
  • React Native: react-native entry in package.json exports #3371
  • Improved React 19 compatibility #3279

Migration guide

This upgrade requires updating all package versions simultaneously.

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

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

Before
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() {}
}
After
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

Before
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() {}
}
After
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

Before
const { args, key } = action.meta;
After
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;
}
Before
processedEntity[key] = visit(
processedEntity[key],
processedEntity,
key,
this.schema[key],
addEntity,
visitedEntities,
storeEntities,
args,
);
After
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 Chat or file a bug