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.
- Web
- React Native
- NextJS
- Expo
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>,
);
import { DataProvider, getDefaultManagers } from '@data-client/react';
import { AppRegistry } from 'react-native';
const managers = [...getDefaultManagers(), new MyManager()];
const Root = () => (
<DataProvider managers={managers}>
<App />
</DataProvider>
);
AppRegistry.registerComponent('MyApp', () => Root);
'use client';
import { getDefaultManagers } from '@data-client/react';
import { DataProvider } from '@data-client/react/nextjs';
const managers = [...getDefaultManagers(), new MyManager()];
export default function Provider({
children,
}: {
children: React.ReactNode;
}) {
return <DataProvider managers={managers}>{children}</DataProvider>;
}
import Provider from './Provider';
export default function RootLayout({ children }) {
return (
<html>
<body>
<Provider>{children}</Provider>
</body>
</html>
);
}
import { getDefaultManagers, DataProvider } from '@data-client/react';
import {
DarkTheme,
DefaultTheme,
ThemeProvider,
} from '@react-navigation/native';
import { useColorScheme } from '@/hooks/useColorScheme';
const managers = [...getDefaultManagers(), new MyManager()];
export default function Provider({
children,
}: {
children: React.ReactNode;
}) {
const colorScheme = useColorScheme();
return (
<ThemeProvider
value={colorScheme === 'dark' ? DarkTheme : DefaultTheme}
>
<DataProvider managers={managers}>{children}</DataProvider>
</ThemeProvider>
);
}
import { Stack } from 'expo-router';
import 'react-native-reanimated';
import Provider from './Provider';
export default function RootLayout() {
return (
<Provider>
<Stack>
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
<Stack.Screen name="+not-found" />
</Stack>
</Provider>
);
}
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.
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