Saltar al contenido principal

El Reactive Data Client

Reactive Data Client ofrece acceso desde el cliente y mutación seguros y de alto rendimiento sobre protocolos de datos remotos. Se pueden usar simultáneamente tanto pull/fetch (REST y GraphQL) como push/stream (WebSockets o Server Sent Events).

Tiene objetivos similares a los de las bases de datos relacionales, pero para clientes de aplicaciones interactivas. Por ello, si tu backend usa un RDBMS como Postgres o MySQL, es un buen indicio de que Reactive Data Client podría ser para ti. Del mismo modo, así como uno puede elegir archivos planos en lugar de almacenamiento en una base de datos, a veces una librería cliente menos potente es suficiente.

No es una tarea menor. Para lograrlo, el diseño de Reactive Data Client apunta a tratar los datos remotos como si fueran locales. Esto significa que la lógica de los componentes no debería ser más compleja que useState y setState.

Define la API​

Los Endpoints son los métodos de tus datos. En esencia, son simplemente funciones asíncronas. Sin embargo, también definen cualquier otro aspecto relevante de la API, como la política de caducidad, el modelo de datos, la validación y los tipos.

Endpoints usados en muchos contextosEndpoints usados en muchos contextos

Al desacoplar las definiciones de los endpoints de su uso, podemos reutilizarlos en muchos contextos.

  • Reutilizarlos fácilmente en distintos componentes facilita ubicar las dependencias de datos junto a donde se usan
  • Reutilizarlos con distintos hooks y acciones imperativas permite comportamientos diferentes con el mismo endpoint
  • Reutilizarlos en distintas plataformas como React Native, React web, o incluso más allá de React, en Angular, Svelte, Vue o Node
  • Publicarlos como paquetes independientes de su consumo

Los endpoints son extensibles y componibles, con implementaciones de protocolos (REST, GraphQL, Websockets+SSE, Img/binary) para empezar rápidamente, extender y compartir patrones comunes.

import { RestEndpoint } from '@data-client/rest';

const getTodo = new RestEndpoint({
urlPrefix: 'https://jsonplaceholder.typicode.com',
path: '/todos/:id',
});

Ubica las dependencias de datos junto a su uso​

Haz que tus componentes sean reutilizables enlazando los datos donde los necesitas con el useSuspense() de una sola línea. Al igual que await, useSuspense() garantiza sus datos una vez que retorna.

import { useSuspense } from '@data-client/react';

export default function TodoDetail({ id }: { id: number }) {
const todo = useSuspense(getTodo, { id });

return <div>{todo.title}</div>;
}

Se acabó el prop drilling y la engorrosa gestión de estado externa. Reactive Data Client garantiza igualdad referencial global, seguridad de los datos y rendimiento.

Ubicar las dependencias junto a su uso también permite que el Server Side Rendering transmita el HTML de forma incremental, reduciendo enormemente el TTFB. Reactive Data Client SSR hidrata automáticamente su store, lo que permite mutaciones interactivas inmediatas con cero fetches del lado del cliente en la primera carga.

Maneja la carga y los errores​

Evita cientos de indicadores de carga colocando AsyncBoundary alrededor de varios componentes que se suspenden.

Normalmente se colocan en o por encima de los límites de navegación, como páginas, rutas o modales.

import { AsyncBoundary } from '@data-client/react';

function App() {
return (
<AsyncBoundary>
<AnotherRoute />
<TodoDetail id={5} />
</AsyncBoundary>
);
}

También se puede usar el manejo de fallback sin Suspense en ciertos casos en React 16 y 17

Mutaciones​

Las mutaciones presentan otro caso de reutilización, esta vez de nuestros datos. Este caso es aún más crítico porque no solo puede producir código inflado, sino también problemas de integridad de datos, tearing y una aplicación con fallos visuales en general.

Cuando llamamos a nuestro método o endpoint de mutación, debemos asegurarnos de que todos los usos de esos datos se actualicen. De lo contrario, nos quedamos con la complejidad, el bajo rendimiento y los tirones de la aplicación que provoca intentar propagar en cascada las actualizaciones de los endpoints.

Mantén los datos consistentes y actualizados​

Las Entities definen nuestro modelo de datos.

Esto habilita un patrón de almacenamiento DRY, que evita los fallos visuales por 'data tearing' y mejora el rendimiento.

import { Entity } from '@data-client/rest';

export class Todo extends Entity {
id = 0;
userId = 0;
title = '';
completed = false;
}

El método pk() (clave primaria) se usa para construir una tabla de búsqueda. Esto se conoce comúnmente como normalización de datos. Para evitar errores, fallos visuales y problemas de rendimiento, es fundamental elegir la estructura de estado correcta (normalizada).

Ahora podemos enlazar nuestra Entity tanto a nuestro endpoint de obtención como al de actualización, lo que nos da integridad de datos en tiempo de ejecución, además de definiciones de TypeScript.

import { RestEndpoint } from '@data-client/rest';

const get = new RestEndpoint({
urlPrefix: 'https://jsonplaceholder.typicode.com',
path: '/todos/:id',
schema: Todo,
});

const update = getTodo.extend({
method: 'PUT',
});

export const TodoResource = { get, update };

Dile a react que se actualice​

Así como con setState(), debemos hacer que React se entere de cualquier mutación para que pueda volver a renderizar.

Controller ofrece esta funcionalidad con tipado seguro. Controller.fetch() nos permite disparar mutaciones.

Podemos usar useController para acceder a él en componentes de React.

import { useController } from '@data-client/react';

function ArticleEdit({ id }: { id: number }) {
const ctrl = useController();
const handleSubmit = data =>
ctrl.fetch(TodoResource.update, { id }, data);
return <ArticleForm onSubmit={handleSubmit} />;
}
Seguimiento imperativo del estado de carga y de error

useLoading() mejora las funciones asíncronas haciendo seguimiento de sus estados de carga y de error.

import { useController, useLoading } from '@data-client/react';

function ArticleEdit({ id }: { id: number }) {
const ctrl = useController();
const [handleSubmit, loading, error] = useLoading(
data => ctrl.fetch(TodoResource.update, { id }, data),
[ctrl],
);
return <ArticleForm onSubmit={handleSubmit} loading={loading} />;
}

Más modelado de datos​

¿Y si nuestra entity no es el elemento de nivel superior? Aquí definimos el endpoint getList con new Collection([Todo]) como su schema. Los Schemas le indican a Reactive Data Client dónde encontrar las Entities. Al colocarla dentro de una lista, Reactive Data Client sabe que debe esperar una respuesta en la que cada elemento de la lista sea la entity especificada.

import { RestEndpoint, Collection } from '@data-client/rest';

// get and update definitions omitted

const getList = new RestEndpoint({
urlPrefix: 'https://jsonplaceholder.typicode.com',
path: '/todos',
schema: new Collection([Todo]),
searchParams: {} as { userId?: string | number } | undefined,
paginationField: 'page',
});

export default (TodoResource = { getList, get, update });

Los Schemas también infieren y hacen cumplir automáticamente el tipo de la respuesta, lo que garantiza que la variable todos tenga un tipo preciso.

import { useSuspense } from '@data-client/react';

export default function TodoList() {
const todos = useSuspense(TodoResource.getList);

return (
<div>
{todos.map(todo => (
<TodoListItem key={todo.pk()} todo={todo} />
))}
</div>
);
}

Ya hemos usado nuestro modelo de datos en tres casos: TodoResource.get, TodoResource.getList y TodoResource.update. La consistencia de los datos (así como la igualdad referencial) estará garantizada entre los endpoints, incluso después de que ocurran mutaciones.

Organización de los Endpoints​

En este punto hemos definido TodoResource.get, TodoResource.getList y TodoResource.update. Quizás hayas notado que estas definiciones de endpoints comparten cierta lógica e información. Por eso, Reactive Data Client recomienda extraer la lógica compartida entre endpoints.

Los Resources son colecciones de endpoints que operan sobre los mismos datos.

import { Entity, resource } from '@data-client/rest';

class Todo extends Entity {
id = 0;
userId = 0;
title = '';
completed = false;
}

const TodoResource = resource({
urlPrefix: 'https://jsonplaceholder.typicode.com',
path: '/todos/:id',
schema: Todo,
searchParams: {} as { userId?: string | number } | undefined,
paginationField: 'page',
});

Introducción a Resource

Endpoints de Resource
// read
// GET https://jsonplaceholder.typicode.com/todos/5
const todo = useSuspense(TodoResource.get, { id: 5 });

// GET https://jsonplaceholder.typicode.com/todos
const todos = useSuspense(TodoResource.getList);

// GET https://jsonplaceholder.typicode.com/todos?userId=1
const todos = useSuspense(TodoResource.getList, { userId: 1 });

// mutate
const ctrl = useController();

// GET https://jsonplaceholder.typicode.com/todos?userId=1
ctrl.fetch(TodoResource.getList.getPage, { userId: 1, page: 2 });

// POST https://jsonplaceholder.typicode.com/todos
ctrl.fetch(TodoResource.getList.push, { title: 'my todo' });

// POST https://jsonplaceholder.typicode.com/todos?userId=1
ctrl.fetch(TodoResource.getList.push, { userId: 1 }, { title: 'my todo' });

// PUT https://jsonplaceholder.typicode.com/todos/5
ctrl.fetch(TodoResource.update, { id: 5 }, { title: 'my todo' });

// PATCH https://jsonplaceholder.typicode.com/todos/5
ctrl.fetch(TodoResource.partialUpdate, { id: 5 }, { title: 'my todo' });

// DELETE https://jsonplaceholder.typicode.com/todos/5
ctrl.fetch(TodoResource.delete, { id: 5 });

Mutaciones sin demora​

Controller.fetch llama al endpoint de mutación y actualiza React según la respuesta. Aunque useTransition mejora la experiencia, la UI en última instancia sigue esperando a que termine el fetch para actualizarse.

En muchos casos, como alternar todo.completed, incrementar un voto positivo o arrastrar y soltar un fotograma, ¡esto puede ser demasiado lento!

Opcionalmente, podemos indicarle a Reactive Data Client que realice los renders de React de inmediato. Para ello tendremos que especificar cómo.

getOptimisticResponse es igual que setState con una función actualizadora. Usando snap para acceder al store y obtener el valor anterior, así como los argumentos del fetch, devolvemos la respuesta del fetch esperada.

const update = new RestEndpoint({
urlPrefix: 'https://jsonplaceholder.typicode.com',
path: '/todos/:id',
method: 'PUT',
schema: Todo,
getOptimisticResponse(snap, { id }, body) {
return {
id,
...body,
};
},
});

Reactive Data Client garantiza la integridad de los datos frente a cualquier posible fallo de red o condición de carrera, así que no te preocupes por los fallos de red, por varias llamadas de mutación que editan los mismos datos, ni por otros problemas comunes de la programación asíncrona.

Mutaciones disparadas de forma remota​

A veces el cambio de los datos se inicia de forma remota, ya sea por otros usuarios del sitio, administradores, etc. Los controles declarativos de política de caducidad permiten un control preciso sobre las actualizaciones debidas al fetching.

Sin embargo, para los datos que cambian con frecuencia (como los tickers de precios de bolsa o las conversaciones en vivo) a veces se usan protocolos basados en push, como Websockets o Server Sent Events. Reactive Data Client tiene una potente capa de middleware llamada Managers, que se puede usar para iniciar actualizaciones de datos cuando se reciben nuevos datos enviados desde el servidor.

StreamManager
import type { Manager, Middleware, ActionTypes } from '@data-client/react';
import { Controller, actionTypes } from '@data-client/react';
import type { EntityInterface } from '@data-client/rest';

export default class StreamManager implements Manager {
declare protected evtSource: WebSocket | EventSource;
declare protected entities: Record<string, EntityInterface>;

constructor(
evtSource: WebSocket | EventSource,
entities: Record<string, EntityInterface>,
) {
this.evtSource = evtSource;
this.entities = entities;
}

middleware: Middleware = controller => {
this.evtSource.onmessage = event => {
try {
const msg: { type: string; args: [any]; data: any } = JSON.parse(
event.data,
);
if (msg.type in this.entities)
controller.set(this.entities[msg.type], ...msg.args, msg.data);
} catch (e) {
console.error('Failed to handle message');
console.error(e);
}
};
return next => async action => next(action);
};

cleanup() {
this.evtSource.close();
}
}

Si no queremos el flujo de datos completo, podemos usar useSubscription() o useLive() para asegurarnos de escuchar únicamente los datos que nos interesan.

Los endpoints con pollFrequency permiten reutilizar los endpoints HTTP existentes, lo que elimina la necesidad de backends adicionales de websocket o SSE. El sondeo (polling) es orquestado globalmente por el SubscriptionManager, así que incluso con muchos componentes suscritos Reactive Data Client nunca hará fetches en exceso.

Depuración​

redux-devtools

Agrega Redux DevTools como extensión de Chrome o extensión de Firefox

Haz clic en el ícono para abrir el inspector, que te permite observar las acciones despachadas, su efecto sobre el estado del caché, así como el estado actual del caché.

Datos simulados​

Escribir Fixtures es un formato estándar que se puede usar con todos los helpers de @data-client/test, así como en tus propios usos.

import type { Fixture } from '@data-client/test';
import { getTodo } from './todo';

const todoDetailFixture: Fixture = {
endpoint: getTodo,
args: [{ id: 5 }] as const,
response: {
id: 5,
title: 'Star Reactive Data Client on Github',
userId: 11,
completed: false,
},
};

Demo​

Explorar en GitHub