Controller
Controller es un singleton que proporciona acceso seguro al store flux y su ciclo de vida de Reactive Data Client.
Controller memoiza todo el acceso al store, lo que permite una garantía de igualdad referencial global y el máximo rendimiento
de renderizado y de recuperación de datos.
Controller se proporciona:
- A los Managers como primer argumento de Manager.middleware
- A React con useController()
- En las pruebas unitarias de hooks con 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>;
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>;
}
Despachadores de Actions
fetch(endpoint, ...args)
Hace fetch del endpoint con los args dados y actualiza la caché de Reactive Data Client con la respuesta o el error al completarse.
- Create
- Update
- Delete
import { useController } from '@data-client/react';
import { PostResource } from './PostResource';
function CreatePost() {
const ctrl = useController();
return (
<form
onSubmit={e =>
ctrl.fetch(PostResource.getList.push, new FormData(e.currentTarget))
}
>
{/* ... */}
</form>
);
}
import { useController } from '@data-client/react';
import { PostResource } from './PostResource';
function UpdatePost({ id }: { id: string }) {
const ctrl = useController();
return (
<form
onSubmit={e =>
ctrl.fetch(PostResource.update, { id }, new FormData(e.currentTarget))
}
>
{/* ... */}
</form>
);
}
import { useController } from '@data-client/react';
import { useCallback } from 'react';
import { useNavigate } from 'react-router';
import { Post, PostResource } from './PostResource';
function PostListItem({ post }: { post: Post }) {
const ctrl = useController();
const navigate = useNavigate();
const handleDelete = useCallback(
async e => {
await ctrl.fetch(PostResource.delete, { id: post.id });
navigate('/');
},
[ctrl, post.id],
);
return (
<div>
<h3>{post.title}</h3>
<button onClick={handleDelete}>X</button>
</div>
);
}
fetch tiene el mismo valor de retorno que el Endpoint que se le pasa.
Al usar schemas, se devuelve el valor desnormalizado
const controller = useController();
const post = await controller.fetch(
PostResource.getList.push,
createPayload,
);
post.title;
post.pk();
Endpoint.sideEffect
sideEffect cambia el comportamiento
true
- Se resuelve antes de confirmar (commit) las actualizaciones de la caché de Reactive Data Client. (React 16, 17)
- Cada llamada siempre provocará un nuevo fetch.
false | undefined
- Se resuelve después de confirmar (commit) las actualizaciones de la caché de Reactive Data Client.
- Las solicitudes idénticas se deduplican globalmente; solo se permite una solicitud en curso a la vez.
- Para asegurar que se inicie una solicitud nueva, asegúrate de abortar cualquier solicitud en curso existente.
fetchIfStale(endpoint, ...args)
Hace fetch solo si el endpoint se considera 'obsoleto'.
Esto puede ser útil al precargar datos, ya que evita obtener de más datos que aún están actualizados.
Un ejemplo con un router de fetch-as-you-render:
{
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,
});
},
},
Explora el ejemplo github-app
expireAll({ testKey })
Establece el estado de caducidad de todas las respuestas que coincidan con testKey como obsoleto.
A veces es útil para activar la actualización solo de los datos que se muestran actualmente cuando hay muchas parametrizaciones en la caché.
import { type Controller, useController } from '@data-client/react';
import { AccountResource, TradeResource, type Trade } from './resources';
import { Form, FormField } from './Form';
const createTradeHandler =
(ctrl: Controller, userId: string) => async (trade: Trade) => {
await ctrl.fetch(TradeResource.getList.push, { user: userId }, trade);
ctrl.expireAll(AccountResource.get);
ctrl.expireAll(AccountResource.getList);
};
function CreateTrade({ userId }: { userId: string }) {
const handleTrade = createTradeHandler(useController(), userId);
return (
<Form onSubmit={handleTrade}>
<FormField name="ticker" />
<FormField name="amount" type="number" />
<FormField name="price" type="number" />
</Form>
);
}
Para reducir la carga, mejorar el rendimiento y mejorar la consistencia del estado, a menudo es mejor incluir los efectos secundarios de la mutación en la respuesta de la mutación.
invalidate(endpoint, ...args)
Fuerza un nuevo fetch y suspenseen useSuspense con el mismo Endpoint y los mismos parámetros.
import { useController, useSuspense } from '@data-client/react';
import { ArticleResource } from './ArticleResource';
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>
);
}
Para actualizar mientras se siguen mostrando datos obsoletos, usa Controller.fetch.
Usa schema.Invalidate para invalidar todos los endpoints que contengan una entity determinada.
Para REST prueba a usar 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 })
Invalida todas las claves de endpoint que coincidan con testKey.
import { useController, useSuspense } from '@data-client/react';
import { ArticleResource } from './ArticleResource';
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>
);
}
Para actualizar mientras se siguen mostrando datos obsoletos, usa en su lugar Controller.expireAll.
Aquí borramos solo los endpoints GET que usan el dominio test.com. Esto significa que los demás dominios permanecen en la caché.
const myDomain = 'http://test.com';
const testKey = (key: string) => key.startsWith(`GET ${myDomain}`);
function useLogout() {
const ctrl = useController();
return () => ctrl.invalidateAll({ testKey });
}
Normalmente también es buena idea borrar la caché ante un 401 (no autorizado) con LogoutManager.
- Web
- React Native
- NextJS
- Expo
import {
DataProvider,
LogoutManager,
getDefaultManagers,
} from '@data-client/react';
import { createRoot } from 'react-dom/client';
import App from './App';
import { unAuth } from '../authentication';
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(),
];
createRoot(document.body).render(
<DataProvider managers={managers}>
<App />
</DataProvider>,
);
import {
DataProvider,
LogoutManager,
getDefaultManagers,
} from '@data-client/react';
import { AppRegistry } from 'react-native';
import App from './App';
import { unAuth } from '../authentication';
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 Root = () => (
<DataProvider managers={managers}>
<App />
</DataProvider>
);
AppRegistry.registerComponent('MyApp', () => Root);
'use client';
import { LogoutManager, getDefaultManagers } from '@data-client/react';
import { DataProvider } from '@data-client/react/nextjs';
import { unAuth } from '../authentication';
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(),
];
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 { Stack } from 'expo-router';
import {
DataProvider,
LogoutManager,
getDefaultManagers,
} from '@data-client/react';
import { unAuth } from '../authentication';
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(),
];
export default function RootLayout() {
return (
<DataProvider managers={managers}>
<Stack>
<Stack.Screen name="index" />
</Stack>
</DataProvider>
);
}
resetEntireStore()
Restablece/borra toda la caché de Reactive Data Client. Las solicitudes en curso no se resolverán.
Normalmente se usa al cerrar sesión o al cambiar de usuario autenticado.
import { useController, useSuspense } from '@data-client/react';
import { useCallback } from 'react';
import { CurrentUserResource } from './CurrentUserResource';
import { impersonateUser } from './auth';
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)
Actualiza cualquier Schema Queryable, o muchas entities a la vez con un schema Array o Values.
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' },
);
El valor se tipa según el schema: una Entity toma sus campos (los números y los strings pueden ser cualquiera de los dos),
mientras que una Collection o All toma una lista de filas. Una Query
toma la entrada del schema que envuelve, ya que set() normaliza ese schema en lugar de revertir process().
ctrl.set(TodoResource.getList.schema, [{ id: '5', completed: true }]);
Cuando cada miembro declara su discriminador como un literal (como readonly type = 'first'), una fila de
Union se comprueba contra el miembro que selecciona, por lo que { type: 'first', secondField: 1 } es un
error. Solo se aceptan los campos declarados, así que una clave leída por una
función schemaAttribute debe declararse en cada miembro.
Se pueden usar funciones como valor cuando se utilizan datos derivados. Esto evita condiciones de carrera.
const id = '2';
ctrl.set(Article, { id }, article => ({ id, votes: article.votes + 1 }));
set([Entity], rows)
Pasa un schema Array ([Todo] o new schema.Array(Todo)) y una lista de filas para actualizar
muchas entities en una sola actualización del store. Cada fila se combina con su entity almacenada; las entities que no están en la lista no se modifican.
ctrl.set(
[Todo],
[
{ id: '5', completed: true },
{ id: '6', completed: false },
],
);
Las filas se tipan según los campos de la Entity; los números y los strings pueden ser cualquiera de los dos, y los valores de tipo objeto, array y Date no se comprueban, ya que las filas son entrada sin procesar.
Para listas que mezclan tipos de Entity, usa una Union; cada fila se almacena según su 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!' },
],
);
Para eliminar muchas entities a la vez, usa Invalidate; las filas solo necesitan sus campos pk:
ctrl.set([new schema.Invalidate(Todo)], [{ id: '5' }, { id: '6' }]);
Para eliminar una sola, pasa el schema Invalidate y su fila:
ctrl.set(new schema.Invalidate(Todo), { id: '5' });
Los schemas Values, en cambio, toman un objeto de filas:
ctrl.set(new schema.Values(Todo), {
'5': { id: '5', completed: true },
'6': { id: '6', completed: false },
});
Los schemas Array, Values e Invalidate no toman args (por lo que Entity.pk() y Entity.process()
reciben []) ni función de actualización. Las filas que comparten una pk se combinan en el orden de la lista, sin
Entity.shouldReorder(). Usa esto en lugar de llamar a set() una vez por fila, por ejemplo al
agrupar en lotes actualizaciones de streams de alta frecuencia.
Prueba ambos botones a continuación. Esta comprobación en el navegador parte de un store vacío y mide el tiempo de un Promise.all de 500 llamadas a set()
frente a un único set() por lotes. Ambos caminos son un solo commit de React, y cada uno escribe 500 precios nuevos.
import React from 'react'; import { useController, useQuery } from '@data-client/react'; import { Ticker, newPrices } from './Ticker'; function PriceStream() { const ctrl = useController(); const [timing, setTiming] = React.useState(''); const first = useQuery(Ticker, { product_id: 'COIN-0' }); const time = async ( label: string, write: (rows: ReturnType<typeof newPrices>) => Promise<unknown>, ) => { const rows = newPrices(); const start = performance.now(); await write(rows); setTiming(`${label}: ${(performance.now() - start).toFixed(1)} ms`); }; const perRow = () => time('500 set() calls', rows => Promise.all( rows.map(row => ctrl.set(Ticker, { product_id: row.product_id }, row)), ), ); // highlight-next-line const batch = () => time('1 batch set()', rows => ctrl.set([Ticker], rows)); return ( <div> <button onClick={perRow}>set() per row</button>{' '} <button onClick={batch}>batch set()</button> <p>COIN-0: {first ? `$${first.price}` : 'no data yet'}</p> <p>{timing}</p> </div> ); } render(<PriceStream />);
setResponse(endpoint, ...args, response)
Almacena response en la caché para el Endpoint y los args dados.
Cualquier componente que esté en suspense para el Endpoint y los args dados se resolverá.
Si ya existen datos para el Endpoint y los args dados, se actualizarán.
import { useController } from '@data-client/react';
import { useEffect } from 'react';
import { EndpointLookup } from './EndpointLookup';
function useWebsocketUpdates(url: string) {
const ctrl = useController();
useEffect(() => {
const websocket = new WebSocket(url);
websocket.onmessage = event => {
const { endpoint, args, data } = JSON.parse(event.data);
ctrl.setResponse(EndpointLookup[endpoint], ...args, data);
};
return () => websocket.close();
}, [ctrl, url]);
}
Esto muestra una prueba de concepto en React; sin embargo, una implementación de websockets con un Manager sería mucho más robusta.
setError(endpoint, ...args, error)
Almacena el resultado de Endpoint y args como el error proporcionado.
resolve(endpoint, { args, response, fetchedAt, error })
Resuelve un fetch específico y almacena response en la caché.
Es similar a setResponse, salvo que activa la resolución de un fetch en curso. Esto significa que la actualización optimista correspondiente dejará de aplicarse.
Se usa en NetworkManager y debe usarse al procesar solicitudes de fetch.
subscribe(endpoint, ...args)
Marca una nueva suscripción a un Endpoint determinado. Esto debe incrementar la suscripción.
useSubscription y useLive lo llaman al montarse.
Puede ser útil para hooks personalizados que se suscriban o cancelen la suscripción según otros factores.
import {
useController,
type EndpointInterface,
type FetchFunction,
type Schema,
} from '@data-client/react';
import { useEffect } from 'react';
function useSubscribe<
E extends EndpointInterface<FetchFunction, Schema | undefined, false | undefined>,
>(endpoint: E, ...args: readonly [...Parameters<E>]) {
const controller = useController();
const key = endpoint.key(...args);
useEffect(() => {
controller.subscribe(endpoint, ...args);
return () => {
controller.unsubscribe(endpoint, ...args);
};
}, [controller, key]);
}
unsubscribe(endpoint, ...args)
Marca la finalización de la suscripción a un Endpoint determinado. Esto debe decrementar la suscripción y, si el contador llega a 0, ya no se recibirán más actualizaciones automáticamente.
useSubscription y useLive lo llaman al desmontarse.
Acceso a datos
get(schema, ...args, state)
Busca cualquier Schema Queryable en state.
Ejemplo
Se usa en useQuery y puede usarse en los Managers para acceder al store de forma segura.
import {
useController,
StateContext,
type Queryable,
type SchemaArgs,
type DenormalizeNullable,
} from '@data-client/react';
import { useContext } from 'react';
/** Oversimplified useQuery */
function useQuery<S extends Queryable>(
schema: S,
...args: SchemaArgs<S>
): DenormalizeNullable<S> | undefined {
const state = useContext(StateContext);
const controller = useController();
return controller.get(schema, ...args, state);
}
getResponse(endpoint, ...args, state)
{
data: DenormalizeNullable<E['schema']>;
expiryStatus: ExpiryStatus;
expiresAt: number;
}
Obtiene la respuesta (globalmente estable a nivel referencial) para un par endpoint/args dado a partir del state proporcionado.
data
Los datos de la respuesta desnormalizados. Garantiza estabilidad referencial global para todos los miembros.
expiryStatus
export enum ExpiryStatus {
Invalid = 1,
InvalidIfStale,
Valid,
}
Valid
- Nunca entrará en suspense.
- Podría hacer fetch si los datos están obsoletos
InvalidIfStale
- Entrará en suspense si los datos están obsoletos.
- Podría hacer fetch si los datos están obsoletos
Invalid
- Siempre entrará en suspense
- Siempre hará fetch
expiresAt
Un número que representa el momento en que caduca. Compáralo con Date.now().
Ejemplo
Se usa en useCache y useSuspense, y puede usarse en los Managers para buscar una respuesta con el state proporcionado.
import {
useController,
StateContext,
type EndpointInterface,
} from '@data-client/react';
import { useContext } from 'react';
/** Oversimplified useCache */
function useCache<E extends EndpointInterface>(
endpoint: E,
...args: readonly [...Parameters<E>]
) {
const state = useContext(StateContext);
const controller = useController();
return controller.getResponse(endpoint, ...args, state).data;
}
import {
type Manager,
type Middleware,
actionTypes,
} from '@data-client/react';
export default class MyManager implements Manager {
declare protected websocket: WebSocket;
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.args,
controller.getState(),
).data,
);
}
next(action);
};
};
cleanup() {
this.websocket.close();
}
}
getError(endpoint, ...args, state)
Obtiene el error, si lo hay, de un endpoint determinado. Devuelve undefined si no hay errores.
snapshot(state, fetchedAt)
Returns a Snapshot.
getState()
Obtiene el estado interno de Reactive Data Client que ya se ha confirmado (commit).
Esto solo debe usarse en manejadores de eventos o en Managers.
Usar getState() en el ciclo de renderizado de React puede provocar desincronización de datos (data tearing).
import { useController } from '@data-client/react';
import { useCallback } from 'react';
import { MyResource } from './resources/MyResource';
import { redirect } from './routing';
function useUpdateHandler(id: string) {
const controller = useController();
return 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],
);
}