Skip to main content

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.

Manager flux flowManager flux flow

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
NetworkManagerTurns fetch dispatches into network calls
SubscriptionManagerHandles polling subscriptions
DevToolsManagerEnables debugging
Extra managers
LogoutManagerHandles 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,
},
}),
];
}

Coin App

More Demos