Saltar al contenido principal

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:

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.

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>
);
}
consejo

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

More Demos

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>
);
}
consejo

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 &amp; suspend
</button>
</div>
);
}
consejo

Para actualizar mientras se siguen mostrando datos obsoletos, usa Controller.fetch.

Invalida muchos endpoints a la vez

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 &amp; suspend
</button>
</div>
);
}
consejo

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.

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

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 }]);
Unions

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 />);
Resultado
Store▶

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.

useQuery.ts
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)​

returns
{
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.

useCache.ts
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;
}
MyManager.ts
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).

aviso

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],
);
}