Guide: Using @data-client Managers for global side effects

SkillDatabases & data

Lets your agent add background data features like live updates, polling, and error reporting to apps using Data Client.

Available today. Use it from your connected AI after setup.

Connect ahel once, and every AI you use reads what you have installed.

Then ask your AI: use the Guide: Using @data-client Managers for global side effects skill

About this capability

Implement @data-client Managers for global/background side effects - websocket, SSE, polling, real-time updates, subscriptions, logging, analytics, metrics/timing, error reporting (Sentry), toast notifications, refetch on window focus or network reconnect, cross-tab sync (BroadcastChannel), offline

What this skill tells your AI

The instructions your AI receives, as published by reactive/data-client in .agents/skills/data-client-manager/SKILL.md and read by ahel’s review.

Managers are singletons that handle global side-effects. Kind of like useEffect() for the central data store. They interface with the store using Controller, and redux middleware is run in response to actions.

Single Responsibility

One concern per Manager; compose many small managers (e.g. transport, subscriptions, logging, auth) rather than one large one.

Use cases

Minimal working examples for each use case live in references/managers.md (see the named section) unless another file is linked:

Use caseReferenceKey technique
Logging actions/state"Middleware logging" in managers.mdlog around await next(action)
Error reporting/monitoring (Sentry)"Error reporting" in managers.mdSET_RESPONSE with action.error
Analytics/metrics (fetch timing)"Metrics" in managers.mdFETCH action's action.meta.promise
Toast notifications on mutations"Notifications" in managers.mdSET_RESPONSE with action.endpoint.sideEffect
Refetch on window focus / network reconnect"Refresh on focus or reconnect" in managers.mdcontroller.expireAll() from event listeners in init()/cleanup()
Cross-tab sync"Cross-tab synchronization" in managers.mdBroadcastChannel + controller.expireAll()
Offline persistence (IndexedDB)"Offline persistence" in managers.mddebounced IndexedDB write of controller.getState() (drop optimistic - not cloneable); restore via DataProvider initialState. Never use localStorage (blocking)
Websocket/SSE push streams"Middleware data stream" in managers.mdcontroller.set() on message; connect in init(), close in cleanup()
Polling/interval updates (ticker)"Dispatching Actions" in Manager.md; TimeManager belowsetInterval + controller.set()
Custom transport subscriptions"Reading and Consuming Actions" in Manager.mdconsume SUBSCRIBE/UNSUBSCRIBE without calling next
Auth: logout on 401, reset store on deauthLogoutManager.mdhandleLogout(controller) + controller.resetEntireStore()

References

For detailed API documentation, see the references directory:

Always use actionTypes when comparing action.type. Refer to Actions for list of actions and their payloads.

Dispatching actions

Controller has dispatchers: ctrl.fetch(), ctrl.fetchIfStale(), ctrl.expireAll(), ctrl.invalidate(), ctrl.invalidateAll(), ctrl.setResponse(), ctrl.set(), ctrl.setError(), ctrl.resetEntireStore(), ctrl.subscribe(), ctrl.unsubscribe().

import type { Manager, Middleware } from '@data-client/core';
import CurrentTime from './CurrentTime';

export default class TimeManager implements Manager {
  declare protected intervalID?: ReturnType<typeof setInterval>;

  middleware: Middleware = controller => {
    this.intervalID = setInterval(() => {
      controller.set(CurrentTime, { id: 1 }, { id: 1, time: Date.now() });
    }, 1000);

    return next => async action => next(action);
  };

  cleanup() {
    clearInterval(this.intervalID);
  }
}

Reading and Consuming Actions

Controller has data accessors: ctrl.getResponse(), ctrl.getState(), ctrl.get(), ctrl.getError(), ctrl.snapshot().

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:
        if (action.endpoint.sideEffect) {
          console.info(
            `${action.endpoint.name} ${JSON.stringify(action.response)}`,
          );
          // wait for state update to be committed to React
          await next(action);
          // get the data from the store, which may be merged with existing state
          const { data } = controller.getResponse(
            action.endpoint,
            ...action.args,
            controller.getState(),
          );
          console.info(`${action.endpoint.name} ${JSON.stringify(data)}`);
          return;
        }
      // actions must be explicitly passed to next middleware
      default:
        return next(action);
    }
  };

  cleanup() {}
}

Always use actionTypes members to check action.type. actionTypes has: FETCH, SET, SET_RESPONSE, RESET, SUBSCRIBE, UNSUBSCRIBE, INVALIDATE, INVALIDATEALL, EXPIREALL

actions docs details the action types and their payloads.

Consuming actions

import type { Manager, Middleware, EntityInterface } from '@data-client/react';
import { actionTypes } from '@data-client/react';
import isEntity from './isEntity';

export default class CustomSubsManager implements Manager {
  declare protected entities: Record<string, EntityInterface>;

  middleware: Middleware = controller => next => async action => {
    switch (action.type) {
      case actionTypes.SUBSCRIBE:
      case actionTypes.UNSUBSCRIBE:
        const { schema } = action.endpoint;
        // only process registered entities
        if (schema && isEntity(schema) && schema.key in this.entities) {
          if (action.type === actionTypes.SUBSCRIBE) {
            this.subscribe(schema.key, action.args[0]?.product_id);
          } else {
            this.unsubscribe(schema.key, action.args[0]?.product_id);
          }

          // consume subscription to prevent it from being processed by other managers
          return Promise.resolve();
        }
      default:
        return next(action);
    }
  };

  cleanup() {}

  subscribe(channel: string, product_id: string) {}
  unsubscribe(channel: string, product_id: string) {}
}

Usage

import { DataProvider, getDefaultManagers } from '@data-client/react';
import ReactDOM from 'react-dom';

const managers = [...getDefaultManagers(), new MyManager()];

ReactDOM.createRoot(document.body).render(
  <DataProvider managers={managers}>
    <App />
  </DataProvider>,
);

Signals

GitHub stars
2k
Forks
99
Last commit
Sep 2026
Advanced
Catalog kind
skill
Gateway key
data-client-manager
Source
github.com/reactive/data-client