Controller
Controller
is a singleton providing safe access to the Reactive Data Client flux store and lifecycle.
Controller
memoizes all store access, allowing a global referential equality guarantee and the fastest rendering
and retrieval performance.
Controller
is provided:
- Managers as the first argument in Manager.middleware
- React with useController()
- Unit testing hooks with renderDataHook()
class Controller {
/*************** Action Dispatchers ***************/
fetch(endpoint, ...args): ReturnType<E>;
fetchIfStale(endpoint, ...args): ReturnType<E> | undefined;
expireAll({ testKey }): Promise<void>;
invalidate(endpoint, ...args): Promise<void>;
invalidateAll({ testKey }): Promise<void>;
resetEntireStore(): Promise<void>;
set(queryable, ...args, value): Promise<void>;
setResponse(endpoint, ...args, response): Promise<void>;
setError(endpoint, ...args, error): Promise<void>;
resolve(endpoint, { args, response, fetchedAt, error }): Promise<void>;
subscribe(endpoint, ...args): Promise<void>;
unsubscribe(endpoint, ...args): Promise<void>;
/*************** Data Access ***************/
get(queryable, ...args, state): Denormalized<typeof queryable>;
getResponse(endpoint, ...args, state): { data; expiryStatus; expiresAt };
getError(endpoint, ...args, state): ErrorTypes | undefined;
snapshot(state: State<unknown>, fetchedAt?: number): SnapshotInterface;
getState(): State<unknown>;
}
Action Dispatchers
fetch(endpoint, ...args)
Fetches the endpoint with given args, updating the Reactive Data Client cache with the response or error upon completion.
- Create
- Update
- Delete
function CreatePost() {
const ctrl = useController();
return (
<form
onSubmit={e =>
ctrl.fetch(PostResource.getList.push, new FormData(e.target))
}
>
{/* ... */}
</form>
);
}
function UpdatePost({ id }: { id: string }) {
const ctrl = useController();
return (
<form
onSubmit={e =>
ctrl.fetch(PostResource.update, { id }, new FormData(e.target))
}
>
{/* ... */}
</form>
);
}
function PostListItem({ post }: { post: PostResource }) {
const ctrl = useController();
const handleDelete = useCallback(
async e => {
await ctrl.fetch(PostResource.delete, { id: post.id });
history.push('/');
},
[ctrl, id],
);
return (
<div>
<h3>{post.title}</h3>
<button onClick={handleDelete}>X</button>
</div>
);
}
fetch
has the same return value as the Endpoint passed to it.
When using schemas, the denormalized value is returned
import { useController } from '@data-client/react';
const post = await controller.fetch(
PostResource.getList.push,
createPayload,
);
post.title;
post.pk();
Endpoint.sideEffect
sideEffect changes the behavior
true
- Resolves before committing Reactive Data Client cache updates.
- Each call will always cause a new fetch.
undefined
- Resolves after committing Reactive Data Client cache updates.
- Identical requests are deduplicated globally; allowing only one inflight request at a time.
- To ensure a new request is started, make sure to abort any existing inflight requests.
fetchIfStale(endpoint, ...args)
Fetches only if endpoint is considered 'stale'.
This can be useful when prefetching data, as it avoids overfetching fresh data.
An example with a fetch-as-you-render router:
{
name: 'IssueList',
component: lazyPage('IssuesPage'),
title: 'issue list',
resolveData: async (
controller: Controller,
{ owner, repo }: { owner: string; repo: string },
searchParams: URLSearchParams,
) => {
const q = searchParams?.get('q') || 'is:issue is:open';
await controller.fetchIfStale(IssueResource.search, {
owner,
repo,
q,
});
},
},
expireAll({ testKey })
Sets all responses' expiry status matching testKey
to Stale.
This is sometimes useful to trigger refresh of only data presently shown when there are many parameterizations in cache.
import { type Controller, useController } from '@data-client/react';
const createTradeHandler = (ctrl: Controller) => async trade => {
await ctrl.fetch(TradeResource.getList.push({ user: user.id }, trade));
ctrl.expireAll(AccountResource.get);
ctrl.expireAll(AccountResource.getList);
};
function CreateTrade({ id }: { id: string }) {
const handleTrade = createTradeHandler(useController());
return (
<Form onSubmit={handleTrade}>
<FormField name="ticker" />
<FormField name="amount" type="number" />
<FormField name="price" type="number" />
</Form>
);
}
To reduce load, improve performance, and improve state consistency; it can often be better to include mutation sideeffects in the mutation response.
invalidate(endpoint, ...args)
Forces refetching and suspense on useSuspense with the same Endpoint and parameters.
function ArticleName({ id }: { id: string }) {
const article = useSuspense(ArticleResource.get, { id });
const ctrl = useController();
return (
<div>
<h1>{article.title}<h1>
<button onClick={() => ctrl.invalidate(ArticleResource.get, { id })}>Fetch & suspend</button>
</div>
);
}
To refresh while continuing to display stale data - Controller.fetch.
Use schema.Invalidate to invalidate every endpoint that contains a given entity.
For REST try using Resource.delete
// deletes MyResource(5)
// this will resuspend MyResource.get({id: '5'})
// and remove it from MyResource.getList
controller.setResponse(MyResource.delete, { id: '5' }, { id: '5' });
invalidateAll({ testKey })
Invalidates all endpoint keys matching testKey
.
function ArticleName({ id }: { id: string }) {
const article = useSuspense(ArticleResource.get, { id });
const ctrl = useController();
return (
<div>
<h1>{article.title}<h1>
<button onClick={() => ctrl.invalidateAll(ArticleResource.get)}>Fetch & suspend</button>
</div>
);
}
To refresh while continuing to display stale data - Controller.expireAll instead.
Here we clear only GET endpoints using the test.com domain. This means other domains remain in cache.
const myDomain = 'http://test.com';
const testKey = (key: string) => key.startsWith(`GET ${myDomain}`);
function useLogout() {
const ctrl = useController();
return () => ctrl.invalidateAll({ testKey });
}
It's usually a good idea to also clear cache on 401 (unauthorized) with LogoutManager as well.
import { DataProvider, LogoutManager, getDefaultManagers } from '@data-client/react';
import ReactDOM from 'react-dom';
import { unAuth } from '../authentication';
const testKey = (key: string) => key.startsWith(`GET ${myDomain}`);
const managers = [
new LogoutManager({
handleLogout(controller) {
// call custom unAuth function we defined
unAuth();
// still reset the store
controller.invalidateAll({ testKey });
},
}),
...getDefaultManagers(),
];
ReactDOM.createRoot(document.body).render(
<DataProvider managers={managers}>
<App />
</DataProvider>,
);
resetEntireStore()
Resets/clears the entire Reactive Data Client cache. All inflight requests will not resolve.
This is typically used when logging out or changing authenticated users.
const USER_NUMBER_ONE: string = "1111";
function UserName() {
const user = useSuspense(CurrentUserResource.get);
const ctrl = useController();
const becomeAdmin = useCallback(() => {
// Changes the current user
impersonateUser(USER_NUMBER_ONE);
ctrl.resetEntireStore();
}, [ctrl]);
return (
<div>
<h1>{user.name}<h1>
<button onClick={becomeAdmin}>Be Number One</button>
</div>
);
}
set(queryable, ...args, value)
ctrl.set(
Todo,
{ id: '5' },
{ id: '5', title: 'tell me friends how great Data Client is' },
);
Functions can be used in the value when derived data is used. This prevents race conditions.
const id = '2';
ctrl.set(Article, { id }, article => ({ id, votes: article.votes + 1 }));
setResponse(endpoint, ...args, response)
Stores response
in cache for given Endpoint and args.
Any components suspending for the given Endpoint and args will resolve.
If data already exists for the given Endpoint and args, it will be updated.
const ctrl = useController();
useEffect(() => {
const websocket = new Websocket(url);
websocket.onmessage = event =>
ctrl.setResponse(
EndpointLookup[event.endpoint],
...event.args,
event.data,
);
return () => websocket.close();
});
This shows a proof of concept in React; however a Manager websockets implementation would be much more robust.
setError(endpoint, ...args, error)
Stores the result of Endpoint and args as the error provided.
resolve(endpoint, { args, response, fetchedAt, error })
Resolves a specific fetch, storing the response
in cache.
This is similar to setResponse, except it triggers resolution of an inflight fetch. This means the corresponding optimistic update will no longer be applies.
This is used in NetworkManager, and should be used when processing fetch requests.
subscribe(endpoint, ...args)
Marks a new subscription to a given Endpoint. This should increment the subscription.
useSubscription and useLive call this on mount.
This might be useful for custom hooks to sub/unsub based on other factors.
const controller = useController();
const key = endpoint.key(...args);
useEffect(() => {
controller.subscribe(endpoint, ...args);
return () => controller.unsubscribe(endpoint, ...args);
}, [controller, key]);
unsubscribe(endpoint, ...args)
Marks completion of subscription to a given Endpoint. This should decrement the subscription and if the count reaches 0, more updates won't be received automatically.
useSubscription and useLive call this on unmount.
Data Access
get(schema, ...args, state)
Looks up any Queryable Schema in state
.
Example
This is used in useQuery and can be used in Managers to safely access the store.
import {
useController,
useCacheState,
type Queryable,
type SchemaArgs,
type DenormalizeNullable,
} from '@data-client/core';
/** Oversimplified useQuery */
function useQuery<S extends Queryable>(
schema: S,
...args: SchemaArgs<S>
): DenormalizeNullable<S> | undefined {
const state = useCacheState();
const controller = useController();
return controller.get(schema, ...args, state);
}
getResponse(endpoint, ...args, state)
{
data: DenormalizeNullable<E['schema']>;
expiryStatus: ExpiryStatus;
expiresAt: number;
}
Gets the (globally referentially stable) response for a given endpoint/args pair from state given.
data
The denormalize response data. Guarantees global referential stability for all members.
expiryStatus
export enum ExpiryStatus {
Invalid = 1,
InvalidIfStale,
Valid,
}
Valid
- Will never suspend.
- Might fetch if data is stale
InvalidIfStale
- Will suspend if data is stale.
- Might fetch if data is stale
Invalid
- Will always suspend
- Will always fetch
expiresAt
A number representing time when it expires. Compare to Date.now().
Example
This is used in useCache, useSuspense and can be used in Managers to lookup a response with the state provided.
import {
useController,
StateContext,
EndpointInterface,
} from '@data-client/core';
/** Oversimplified useCache */
function useCache<E extends EntityInterface>(
endpoint: E,
...args: readonly [...Parameters<E>]
) {
const state = useContext(StateContext);
const controller = useController();
return controller.getResponse(endpoint, ...args, state).data;
}
import type { Manager, Middleware, actionTypes } from '@data-client/core';
import type { EndpointInterface } from '@data-client/endpoint';
export default class MyManager implements Manager {
middleware: Middleware = controller => {
return next => async action => {
if (action.type === actionTypes.FETCH_TYPE) {
console.log('The existing response of the requested fetch');
console.log(
controller.getResponse(
action.endpoint,
...(action.meta.args as Parameters<typeof action.endpoint>),
controller.getState(),
).data,
);
}
next(action);
};
};
cleanup() {
this.websocket.close();
}
}
getError(endpoint, ...args, state)
Gets the error, if any, for a given endpoint. Returns undefined for no errors.
snapshot(state, fetchedAt)
Returns a Snapshot.
getState()
Gets the internal state of Reactive Data Client that has already been committed.
This should only be used in event handlers or Managers.
Using getState() in React's render lifecycle can result in data tearing.
const controller = useController();
const updateHandler = useCallback(
async updatePayload => {
const response = await controller.fetch(
MyResource.update,
{ id },
updatePayload,
);
// the fetch has completed, but react has not yet re-rendered
// this lets use sequence after the next re-render
// we're working on a better solution to this specific case
setTimeout(() => {
const { data: denormalized } = controller.getResponse(
MyResource.update,
{ id },
updatePayload,
controller.getState(),
);
redirect(denormalized.getterUrl);
}, 40);
},
[id],
);