Managers and Middleware
Reactive Data Client uses the flux store pattern, which is characterized by an easy to understand and debug the store's undirectional data flow. State updates are performed by a reducer function.

In flux architectures, it is critical all functions in the flux loop are pure. Managers provide centralized orchestration of side effects. In other words, they are the means to interface with the world outside Data Client.
For instance, NetworkManager orchestrates data fetching and SubscriptionManager keeps track of which resources are subscribed with useLive or useSubscription. By centralizing control, NetworkManager automatically deduplicates fetches, and SubscriptionManager will keep only actively rendered resources updated.
This makes Managers the best way to integrate additional side-effects like logging, error reporting, metrics, notifications, data streams, refreshing on focus or reconnect, cross-tab synchronization, and offline persistence. They can also be customized to change core behaviors.
| Default managers | |
|---|---|
| NetworkManager | Turns fetch dispatches into network calls |
| SubscriptionManager | Handles polling subscriptions |
| DevToolsManager | Enables debugging |
| Extra managers | |
| LogoutManager | Handles HTTP 401 (or other logout conditions) |
Examples
Reactive Data Client improves type-safety and ergonomics by performing dispatches and store access with its Controller
Middleware logging
import type { Manager, Middleware } from '@data-client/core';
export default class LoggingManager implements Manager {
middleware: Middleware = controller => next => async action => {
console.log('before', action, controller.getState());
await next(action);
console.log('after', action, controller.getState());
};
cleanup() {}
}
Error reporting
Report failed fetches to monitoring services like Sentry by inspecting
SET_RESPONSE actions with error set.
import {
type Manager,
type Middleware,
actionTypes,
} from '@data-client/react';
import { captureException } from '@sentry/react';
export default class ErrorReportManager implements Manager {
middleware: Middleware = controller => next => async action => {
if (action.type === actionTypes.SET_RESPONSE && action.error)
captureException(action.response, {
extra: { endpoint: action.endpoint.name, args: action.args },
});
return next(action);
};
cleanup() {}
}
Metrics
Track fetch timing by observing FETCH actions. action.meta.promise
resolves when the fetch completes.
import {
type Manager,
type Middleware,
actionTypes,
} from '@data-client/react';
import { trackTiming } from './analytics';
export default class MetricsManager implements Manager {
middleware: Middleware = controller => next => async action => {
if (action.type === actionTypes.FETCH) {
const start = performance.now();
action.meta.promise.finally(() => {
trackTiming(action.endpoint.name, performance.now() - start);
});
}
return next(action);
};
cleanup() {}
}
Notifications (toasts)
Show a toast when any mutation succeeds or fails.
import {
type Manager,
type Middleware,
actionTypes,
} from '@data-client/react';
import { toast } from './toast';
export default class ToastManager implements Manager {
middleware: Middleware = controller => next => async action => {
if (
action.type === actionTypes.SET_RESPONSE &&
action.endpoint.sideEffect
) {
if (action.error) toast.error(`${action.endpoint.name} failed`);
else toast.success(`${action.endpoint.name} succeeded`);
}
return next(action);
};
cleanup() {}
}
Refresh on focus or reconnect
Controller.expireAll() marks data as Stale, triggering refetch of any actively rendered data without suspending (stale-while-revalidate). init() and cleanup() manage the event listeners.
import type { Manager, Middleware, Controller } from '@data-client/react';
export default class RefreshManager implements Manager {
declare protected controller: Controller;
protected handle = () =>
this.controller.expireAll({ testKey: () => true });
middleware: Middleware = controller => {
this.controller = controller;
return next => async action => next(action);
};
init() {
window.addEventListener('focus', this.handle);
window.addEventListener('online', this.handle);
}
cleanup() {
window.removeEventListener('focus', this.handle);
window.removeEventListener('online', this.handle);
}
}
Cross-tab synchronization
When a mutation succeeds in one tab, mark data stale in all other tabs using BroadcastChannel.
import {
type Manager,
type Middleware,
actionTypes,
} from '@data-client/react';
export default class TabSyncManager implements Manager {
protected channel = new BroadcastChannel('data-client');
middleware: Middleware = controller => {
this.channel.onmessage = () =>
controller.expireAll({ testKey: () => true });
return next => async action => {
if (
action.type === actionTypes.SET_RESPONSE &&
action.endpoint.sideEffect &&
!action.error
)
this.channel.postMessage('mutation');
return next(action);
};
};
cleanup() {
this.channel.close();
}
}
Offline persistence
Persist the store with IndexedDB
(here via idb-keyval); restore it with
DataProvider's initialState. IndexedDB writes are
asynchronous and use structured clone
instead of blocking the main thread with JSON serialization like localStorage would.
Debouncing writes keeps rapid action bursts cheap. Consider expiry times
when restoring.
import type { Manager, Middleware } from '@data-client/react';
import { set } from 'idb-keyval';
export default class PersistManager implements Manager {
declare protected timer?: ReturnType<typeof setTimeout>;
middleware: Middleware = controller => next => async action => {
await next(action);
// debounce: persist at most once per second
clearTimeout(this.timer);
this.timer = setTimeout(() => {
// in-flight optimistic updates reference functions, so are not persistable
const state = { ...controller.getState(), optimistic: [] };
set('data-client', state);
}, 1000);
};
cleanup() {
clearTimeout(this.timer);
}
}
import { get } from 'idb-keyval';
const initialState = await get('data-client');
createRoot(document.body).render(
<DataProvider initialState={initialState} managers={managers}>
<App />
</DataProvider>,
);
Middleware data stream (push-based)
Adding a manager to process data pushed from the server by websockets or Server Sent Events ensures we can maintain fresh data when the data updates are independent of user action. For example, a trading app's price, or a real-time collaborative editor.
import { type Manager, type Middleware, Controller } from '@data-client/react';
import type { Entity } from '@data-client/rest';
export default class StreamManager implements Manager {
declare protected controller: Controller;
declare protected evtSource: WebSocket; // | EventSource;
declare protected createEventSource: () => WebSocket | EventSource;
declare protected entities: Record<string, typeof Entity>;
constructor(
createEventSource: () => WebSocket | EventSource,
entities: Record<string, EntityInterface>,
) {
this.createEventSource = createEventSource;
this.entities = entities;
}
middleware: Middleware = controller => {
this.controller = controller;
return next => async action => next(action);
}
connect() {
this.evtSource = this.createEventSource();
this.evtSource.onmessage = event => {
try {
const msg = JSON.parse(event.data);
if (msg.type in this.entities)
controller.set(this.entities[msg.type], ...msg.args, msg.data);
} catch (e) {
console.error('Failed to handle message');
console.error(e);
}
};
}
init() {
this.connect();
}
cleanup() {
this.evtSource?.close();
}
}
Controller.set() allows directly updating Querable Schemas
directly with event.data.
Skipping DevTools for high-frequency updates
When using WebSockets or other real-time data sources, you may want to skip logging certain high-frequency actions to DevToolsManager to avoid overwhelming the browser extension.
import { getDefaultManagers, actionTypes } from '@data-client/react';
import StreamManager from './StreamManager';
import { Ticker } from './Ticker';
export default function getManagers() {
return [
new StreamManager(
() => new WebSocket('wss://ws-feed.example.com'),
{ ticker: Ticker },
),
...getDefaultManagers({
devToolsManager: {
// Increase latency buffer for high-frequency updates
latency: 1000,
// Skip WebSocket SET actions to avoid log spam
predicate: (state, action) =>
action.type !== actionTypes.SET || action.schema !== Ticker,
},
}),
];
}