Skip to main content

Manager

Managers are singletons that handle global side-effects. Kind of like useEffect() for the central data store.

The default managers orchestrate the complex asynchronous behavior that Data Client provides out of the box. These can easily be configured with getDefaultManagers(), and extended with your own custom Managers.

Managers must implement middleware, which hooks them into the central store's control flow. Additionally, cleanup() and init() hook into the store's lifecycle for setup/teardown behaviors.

type Dispatch = (action: ActionTypes) => Promise<void>;

type Middleware = (controller: Controller) => (next: Dispatch) => Dispatch;

interface Manager {
middleware: Middleware;
cleanup(): void;
init?: (state: State<any>) => void;
}

Lifecycle

middleware

middleware is very similar to a redux middleware. The only differences is that the next() function returns a Promise. This promise resolves when the reducer update is committed when using <DataProvider />. This is necessary since the commit phase is asynchronously scheduled. This enables building managers that perform work after the DOM is updated and also with the newly computed state.

Since redux is fully synchronous, an adapter must be placed in front of Reactive Data Client style middleware to ensure they can consume a promise. Conversely, redux middleware must be changed to pass through promises.

Middlewares will intercept actions that are dispatched and then potentially dispatch their own actions as well. To read more about middlewares, see the redux documentation.

init(state)

Called with initial state after provider is mounted. Can be useful to run setup at start that relies on state actually existing.

cleanup()

Provides any cleanup of dangling resources after manager is no longer in use.

Adding managers to Reactive Data Client

Use the managers prop of DataProvider. Be sure to hoist to module level or wrap in a useMemo() to ensure they are not recreated. Managers have internal state, so it is important to not constantly recreate them.

/index.tsx
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>,
);

Control flow

Managers integrate with the DataProvider store with their lifecycles and middleware. They orchestrate complex control flows by interfacing via intercepting and dispatching actions, as well as reading the internal state.

Manager flux flowManager flux flow

The job of middleware is to dispatch actions, respond to actions, or both.

Dispatching Actions

Controller provides type-safe action dispatchers.

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

export default class TimeManager implements Manager {
  protected declare 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

actionTypes includes all constants to distinguish between different actions.

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

In conditional blocks, the action type narrows, encouraging safe access to its members.

In case we want to 'handle' a certain action, we can 'consume' it by not calling next.

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 {
  protected declare 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 if we use it
          return Promise.resolve();
        }
      default:
        return next(action);
    }
  };

  cleanup() {}

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

By return Promise.resolve(); instead of calling next(action), we prevent managers listed after this one from seeing that action.

Types: FETCH, SET, SET_RESPONSE, RESET, SUBSCRIBE, UNSUBSCRIBE, INVALIDATE, INVALIDATEALL, EXPIREALL