Pular para o conteúdo principal

O Reactive Data Client

O Reactive Data Client oferece acesso e mutação seguros e performáticos sobre protocolos de dados remotos. Tanto pull/fetch (REST e GraphQL) quanto push/stream (WebSockets ou Server Sent Events) podem ser usados simultaneamente.

Seus objetivos são semelhantes aos dos bancos de dados relacionais, mas voltados a clientes de aplicações interativas. Por isso, se o seu backend usa um RDBMS como Postgres ou MySQL, esse é um bom indício de que o Reactive Data Client pode ser para você. Da mesma forma, assim como alguém pode escolher arquivos simples em vez de armazenamento em banco de dados, às vezes uma biblioteca cliente menos poderosa é suficiente.

Não é uma tarefa pequena. Para alcançá-la, o design do Reactive Data Client busca tratar dados remotos como se fossem locais. Isso significa que a lógica dos componentes não deve ser mais complexa do que useState e setState.

Define API​

Endpoints são os métodos dos seus dados. Em sua essência, são simplesmente funções assíncronas. No entanto, eles também definem qualquer outra coisa relevante para a API, como política de expiração, modelo de dados, validação e tipos.

Endpoints usados em muitos contextosEndpoints usados em muitos contextos

Ao desacoplar as definições de endpoints do seu uso, conseguimos reutilizá-los em muitos contextos.

  • A reutilização fácil em diferentes componentes facilita a colocalização das dependências de dados
  • A reutilização com diferentes composables e ações imperativas permite comportamentos diferentes com o mesmo endpoint
  • A reutilização entre diferentes plataformas como Vue web ou até além do Vue, em React, Angular, Svelte ou Node
  • Publicados como pacotes, independentes de seu consumo

Endpoints são extensíveis e componíveis, com implementações de protocolos (REST, GraphQL, Websockets+SSE) para começar rapidamente, estender e compartilhar padrões comuns.

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

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

Colocalize as dependências de dados​

Torne seus componentes reutilizáveis vinculando os dados onde você precisa deles com o useSuspense() de uma linha. Assim como o await, useSuspense() garante seus dados assim que retorna.

TodoDetail.vue
<script setup lang="ts">
import { useSuspense } from '@data-client/vue';
import { getTodo } from './api/Todo';

const props = defineProps<{ id: number }>();
const todo = await useSuspense(getTodo, () => ({ id: props.id }));
</script>

<template>
<div>{{ todo.title }}</div>
</template>

Chega de prop drilling ou de gerenciamento de estado externo trabalhoso. O Reactive Data Client garante igualdade referencial global, segurança dos dados e desempenho.

Trate carregamento/erro​

Evite centenas de spinners de carregamento colocando o <Suspense /> nativo do Vue ao redor de vários componentes que suspendem. Seu slot #fallback é renderizado enquanto qualquer descendente ainda estiver aguardando dados. Os erros são capturados com onErrorCaptured().

Normalmente eles são colocados em limites de navegação, como páginas, rotas ou modais, ou acima deles.

App.vue
<script setup lang="ts">
import { onErrorCaptured, ref } from 'vue';
import AnotherRoute from './AnotherRoute.vue';
import TodoDetail from './TodoDetail.vue';

const error = ref<Error | null>(null);
onErrorCaptured(err => {
error.value = err;
return false;
});
</script>

<template>
<div v-if="error">Error: {{ error.message }}</div>
<Suspense v-else>
<template #default>
<AnotherRoute />
<TodoDetail :id="5" />
</template>
<template #fallback>
<Loading />
</template>
</Suspense>
</template>

O tratamento de fallback sem Suspense também pode ser usado em certos casos.

Mutações​

As mutações apresentam outro caso de reutilização, desta vez dos nossos dados. Este caso é ainda mais crítico porque pode levar não apenas a código inchado, mas também a problemas de integridade dos dados, tearing e travamentos gerais da aplicação.

Quando chamamos nosso método/endpoint de mutação, precisamos garantir que todos os usos desses dados sejam atualizados. Caso contrário, ficamos presos à complexidade, ao desempenho ruim e aos travamentos da aplicação ao tentar propagar em cascata a atualização de endpoints.

Mantenha os dados consistentes e atualizados​

Entities definem nosso modelo de dados.

Isso habilita um padrão de armazenamento DRY, que evita o 'data tearing' e melhora o desempenho.

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

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

O método pk() (chave primária) é usado para construir uma tabela de consulta. Isso é comumente conhecido como normalização de dados. Para evitar bugs, travamentos da aplicação e problemas de desempenho, é fundamental escolher a estrutura de estado (normalizada) certa.

Agora podemos vincular nossa Entity tanto ao endpoint get quanto ao endpoint update, garantindo a integridade dos dados em tempo de execução, além das definições 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 };

Avise o Vue para atualizar​

Assim como em uma atribuição a um ref(), precisamos avisar o Vue sobre quaisquer mutações para que ele possa rerrenderizar.

O Controller oferece essa funcionalidade com tipagem segura. Controller.fetch() nos permite disparar mutações.

Podemos usar useController para acessá-lo em componentes Vue.

ArticleEdit.vue
<script setup lang="ts">
import { useController } from '@data-client/vue';
import { TodoResource } from './resources/Todo';
import ArticleForm from './ArticleForm.vue';

const props = defineProps<{ id: number }>();
const ctrl = useController();
const handleSubmit = data =>
ctrl.fetch(TodoResource.update, { id: props.id }, data);
</script>

<template>
<ArticleForm @submit="handleSubmit" />
</template>
Acompanhando o estado imperativo de carregamento/erro

useLoading() aprimora funções assíncronas acompanhando seus estados de carregamento e de erro.

ArticleEdit.vue
<script setup lang="ts">
import { useController, useLoading } from '@data-client/vue';
import { TodoResource } from './resources/Todo';
import ArticleForm from './ArticleForm.vue';

const props = defineProps<{ id: number }>();
const ctrl = useController();
const [handleSubmit, loading, error] = useLoading(data =>
ctrl.fetch(TodoResource.update, { id: props.id }, data),
);
</script>

<template>
<ArticleForm @submit="handleSubmit" :loading="loading" />
</template>

Mais modelagem de dados​

E se a nossa entity não for o item de nível superior? Aqui definimos o endpoint getList com new Collection([Todo]) como seu schema. Os Schemas dizem ao Reactive Data Client onde encontrar as Entities. Ao colocá-la dentro de uma lista, o Reactive Data Client sabe que deve esperar uma resposta em que cada item da lista é a 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 });

Os Schemas também inferem e impõem automaticamente o tipo da resposta, garantindo que a variável todos seja tipada com precisão.

TodoList.vue
<script setup lang="ts">
import { useSuspense } from '@data-client/vue';
import { TodoResource } from './resources/Todo';
import TodoListItem from './TodoListItem.vue';

const todos = await useSuspense(TodoResource.getList);
</script>

<template>
<div>
<TodoListItem v-for="todo in todos" :key="todo.pk()" :todo="todo" />
</div>
</template>

Agora usamos nosso modelo de dados em três casos: TodoResource.get, TodoResource.getList e TodoResource.update. A consistência dos dados (assim como a igualdade referencial) será garantida entre os endpoints, mesmo depois que ocorrerem mutações.

Organizando Endpoints​

Neste ponto, definimos TodoResource.get, TodoResource.getList e TodoResource.update. Você pode ter notado que essas definições de endpoints compartilham alguma lógica e informação. Por isso, o Reactive Data Client incentiva extrair a lógica compartilhada entre endpoints.

Resources são coleções de endpoints que operam sobre os mesmos dados.

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

Introdução ao Resource

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

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

// GET https://jsonplaceholder.typicode.com/todos?userId=1
const todos = await 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 });

Mutações sem atraso​

Controller.fetch chama o endpoint de mutação e atualiza o Vue com base na resposta. A UI ainda precisa, em última instância, esperar a conclusão do fetch para atualizar.

Em muitos casos, como alternar todo.completed, incrementar um upvote ou arrastar e soltar um quadro, isso pode ser lento demais!

Opcionalmente, podemos pedir ao Reactive Data Client que execute as renderizações do Vue imediatamente. Para isso, precisamos especificar como.

getOptimisticResponse é como uma função atualizadora. Usando snap para acessar o store e obter o valor anterior, além dos argumentos do fetch, retornamos a resposta de 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,
};
},
});

O Reactive Data Client garante a integridade dos dados contra qualquer possível falha de rede ou race condition, então não se preocupe com falhas de rede, várias chamadas de mutação editando os mesmos dados ou outros problemas comuns da programação assíncrona.

Mutações disparadas remotamente​

Às vezes a mudança nos dados é iniciada remotamente, seja por outros usuários do site, administradores etc. Os controles declarativos de política de expiração permitem um controle rigoroso sobre as atualizações causadas por fetches.

No entanto, para dados que mudam com frequência (como cotações de bolsa ou conversas ao vivo), às vezes são usados protocolos baseados em push, como Websockets ou Server Sent Events. O Reactive Data Client tem uma poderosa camada de middleware chamada Managers, que pode ser usada para iniciar atualizações de dados ao receber novos dados enviados pelo servidor.

StreamManager
import type { Manager, Middleware, ActionTypes } from '@data-client/vue';
import { Controller, actionTypes } from '@data-client/vue';
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();
}
}

Se não quisermos o stream de dados completo, podemos usar useSubscription() ou useLive() para garantir que escutamos apenas os dados que nos interessam.

Endpoints com pollFrequency permitem reutilizar os endpoints HTTP existentes, eliminando a necessidade de backends adicionais de websocket ou SSE. O polling é orquestrado globalmente pelo SubscriptionManager, então, mesmo com muitos componentes inscritos, o Reactive Data Client nunca fará fetches em excesso.

Depuração​

redux-devtools

Adicione o Redux DevTools para extensão do Chrome ou extensão do Firefox

Clique no ícone para abrir o inspetor, que permite observar as ações despachadas, seu efeito no estado do cache, bem como o estado atual do cache.

Dados de mock​

Escrever Fixtures é um formato padrão que pode ser usado em todos os helpers de @data-client/test, bem como nos seus próprios 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​

Explore o exemplo vue-todo-app

More Demos

Explore on GitHub