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
- Vue with useController()
- Unit testing composables with
renderDataCompose()from@data-client/vue/test
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>;
set([Entity], rows): 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
<script setup lang="ts">
import { useController } from '@data-client/vue';
import { PostResource } from './PostResource';
const ctrl = useController();
const handleSubmit = (e: Event) =>
ctrl.fetch(
PostResource.getList.push,
new FormData(e.target as HTMLFormElement),
);
</script>
<template>
<form @submit.prevent="handleSubmit"><!-- ... --></form>
</template>
<script setup lang="ts">
import { useController } from '@data-client/vue';
import { PostResource } from './PostResource';
const props = defineProps<{ id: string }>();
const ctrl = useController();
const handleSubmit = (e: Event) =>
ctrl.fetch(
PostResource.update,
{ id: props.id },
new FormData(e.target as HTMLFormElement),
);
</script>
<template>
<form @submit.prevent="handleSubmit"><!-- ... --></form>
</template>
<script setup lang="ts">
import { useController } from '@data-client/vue';
import { useRouter } from 'vue-router';
import { PostResource } from './PostResource';
const props = defineProps<{ post: PostResource }>();
const ctrl = useController();
const router = useRouter();
const handleDelete = async () => {
await ctrl.fetch(PostResource.delete, { id: props.post.id });
router.push('/');
};
</script>
<template>
<div>
<h3>{{ post.title }}</h3>
<button @click="handleDelete">X</button>
</div>
</template>
fetch has the same return value as the Endpoint passed to it.
When using schemas, the denormalized value is returned
const controller = useController();
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. (React 16, 17)
- Each call will always cause a new fetch.
false | 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.
<script setup lang="ts">
import { useController } from '@data-client/vue';
import { AccountResource, TradeResource } from './resources';
const props = defineProps<{ userId: string }>();
const ctrl = useController();
const handleTrade = async (trade: Trade) => {
await ctrl.fetch(
TradeResource.getList.push,
{ user: props.userId },
trade,
);
ctrl.expireAll(AccountResource.get);
ctrl.expireAll(AccountResource.getList);
};
</script>
<template>
<TradeForm @submit="handleTrade" />
</template>
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 on useSuspense with the same Endpoint and parameters. Mounted components keep showing their current data until the refetch resolves.
<script setup lang="ts">
import { useController, useSuspense } from '@data-client/vue';
import { ArticleResource } from './ArticleResource';
const props = defineProps<{ id: string }>();
const ctrl = useController();
const article = await useSuspense(ArticleResource.get, () => ({
id: props.id,
}));
</script>
<template>
<div>
<h1>{{ article.title }}</h1>
<button @click="ctrl.invalidate(ArticleResource.get, { id })">
Refetch
</button>
</div>
</template>
Use schema.Invalidate to invalidate every endpoint that contains a given entity.
For REST try using Resource.delete
// deletes MyResource(5)
// this will refetch 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.
<script setup lang="ts">
import { useController, useSuspense } from '@data-client/vue';
import { ArticleResource } from './ArticleResource';
const props = defineProps<{ id: string }>();
const ctrl = useController();
const article = await useSuspense(ArticleResource.get, () => ({
id: props.id,
}));
</script>
<template>
<div>
<h1>{{ article.title }}</h1>
<button @click="ctrl.invalidateAll(ArticleResource.get)">
Refetch
</button>
</div>
</template>
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 { createApp } from 'vue';
import {
DataClientPlugin,
LogoutManager,
getDefaultManagers,
} from '@data-client/vue';
import { unAuth } from '../authentication';
import App from './App.vue';
const myDomain = 'http://test.com';
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(),
];
const app = createApp(App);
app.use(DataClientPlugin, { managers });
app.mount('#app');
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.
<script setup lang="ts">
import { useController, useSuspense } from '@data-client/vue';
import { CurrentUserResource } from './CurrentUserResource';
const USER_NUMBER_ONE: string = '1111';
const user = await useSuspense(CurrentUserResource.get);
const ctrl = useController();
const becomeAdmin = () => {
// Changes the current user
impersonateUser(USER_NUMBER_ONE);
ctrl.resetEntireStore();
};
</script>
<template>
<div>
<h1>{{ user.name }}</h1>
<button @click="becomeAdmin">Be Number One</button>
</div>
</template>
set(queryable, ...args, value)
Updates any Queryable Schema, or many entities at once with an Array or Values schema.
ctrl.set(
Todo,
// which Todo to update
{ id: '5' },
// merge this data into the Todo in the store
{ id: '5', title: 'tell me friends how great Data Client is' },
);
The value is typed by the schema: an Entity takes its fields (numbers and strings may be either),
while a Collection or All takes a list of rows. A Query
takes the input of the schema it wraps, since set() normalizes that schema rather than reversing process().
ctrl.set(TodoResource.getList.schema, [{ id: '5', completed: true }]);
When each member declares its discriminator as a literal (like readonly type = 'first'), a
Union row is checked against the member it selects, so { type: 'first', secondField: 1 } is an
error. Only declared fields are accepted, so a key read by a
schemaAttribute function must be declared on each member.
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 }));
set([Entity], rows)
Pass an Array schema ([Todo] or new schema.Array(Todo)) and a list of rows to update
many entities in one store update. Each row merges with its stored entity; entities not in the list are untouched.
ctrl.set(
[Todo],
[
{ id: '5', completed: true },
{ id: '6', completed: false },
],
);
Rows are typed by the Entity's fields; numbers and strings may be either, and object, array and Date values are not checked since rows are raw input.
For lists that mix Entity types, use a Union; each row is stored by its type:
const Feed = new schema.Union({ post: Post, comment: Comment }, 'type');
ctrl.set(
[Feed],
[
{ id: '1', type: 'post', title: 'Hello' },
{ id: '7', type: 'comment', body: 'Nice!' },
],
);
To delete many entities at once, use Invalidate; rows only need their pk fields:
ctrl.set([new schema.Invalidate(Todo)], [{ id: '5' }, { id: '6' }]);
Values schemas take an object of rows instead:
ctrl.set(new schema.Values(Todo), {
'5': { id: '5', completed: true },
'6': { id: '6', completed: false },
});
Array and Values schemas take no args (so Entity.pk() and Entity.process()
receive []) and no updater function. Rows that share a pk merge in list order, without
Entity.shouldReorder(). Use this instead of calling set() once per row, such as when
batching high-frequency stream updates.
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();
let websocket: WebSocket;
onMounted(() => {
websocket = new WebSocket(url);
websocket.onmessage = event =>
ctrl.setResponse(
EndpointLookup[event.endpoint],
...event.args,
event.data,
);
});
onUnmounted(() => websocket.close());
This shows a proof of concept in Vue; 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 composables to sub/unsub based on other factors.
const controller = useController();
// args can be a ref, computed or getter; this re-runs when it changes
watchEffect(onCleanup => {
const currentArgs = toValue(args);
controller.subscribe(endpoint, ...currentArgs);
onCleanup(() => controller.unsubscribe(endpoint, ...currentArgs));
});
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.
In components, useQuery() keeps the result reactive. In event handlers, pass getState() to read the latest store:
const ctrl = useController();
const toggle = (id: string) => {
const todo = ctrl.get(Todo, { id }, ctrl.getState());
if (todo) ctrl.set(Todo, { id }, { id, completed: !todo.completed });
};
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.
In event handlers, pass getState() to read the latest store, as in the getState() example.
import {
type Manager,
type Middleware,
actionTypes,
} from '@data-client/vue';
export default class MyManager implements Manager {
middleware: Middleware = controller => {
return next => async action => {
if (action.type === actionTypes.FETCH) {
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 a computed() or template won't update when the store changes. Use
useQuery() or useCache() there instead.
const controller = useController();
const handleShare = () => {
// reads the latest store without making this handler reactive
const { data: article } = controller.getResponse(
ArticleResource.get,
{ id: props.id },
controller.getState(),
);
if (article) navigator.share({ title: article.title, url: article.url });
};
Mutations resolve before the store is updated, so read their result from
the value fetch() resolves with rather than getState().