Skip to main content

The Reactive Data Client

Reactive Data Client provides safe and performant client access and mutation over remote data protocols. Both pull/fetch (REST and GraphQL) and push/stream (WebSockets or Server Sent Events) can be used simultaneously.

It has similar goals to Relational Databases but for interactive application clients. Because of this, if your backend uses a RDBMS like Postgres or MySQL this is a good indication Reactive Data Client might be for you. Respectively, just like one might choose flat files over database storage, sometimes a less powerful client library is sufficient.

This is no small task. To achieve this, Reactive Data Client' design is aimed at treating remote data like it is local. This means component logic should be no more complex than useState and setState.

Define API

Endpoints are the methods of your data. At their core they are simply asynchronous functions. However, they also define anything else relevant to the API like expiry policy, data model, validation, and types.

Endpoints used in many contextsEndpoints used in many contexts

By decoupling endpoint definitions from their usage, we are able to reuse them in many contexts.

  • Easy reuse in different components eases co-locating data dependencies
  • Reuse with different hooks and imperative actions allows different behaviors with the same endpoint
  • Reuse across different platforms like React Native, React web, or even beyond React in Angular, Svelte, Vue, or Node
  • Published as packages independent of their consumption

Endpoints are extensible and composable, with protocol implementations (REST, GraphQL, Websockets+SSE, Img/binary) to get started quickly, extend, and share common patterns.

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

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

Co-locate data dependencies

Make your components reusable by binding the data where you need it with the one-line useSuspense(). Much like await, useSuspense() guarantees its data once it returns.

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

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

return <div>{todo.title}</div>;
}
render(<TodoDetail id={1} />);

No more prop drilling, or cumbersome external state management. Reactive Data Client guarantees global referential equality, data safety and performance.

Co-location also allows Server Side Rendering to incrementally stream HTML, greatly reducing TTFB. Reactive Data Client SSR automatically hydrates its store, allowing immediate interactive mutations with zero client-side fetches on first load.

Handle loading/error

Avoid 100s of loading spinners by placing AsyncBoundary around many suspending components.

Typically these are placed at or above navigational boundaries like pages, routes or modals.

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

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

Non-Suspense fallback handling can also be used for certain cases in React 16 and 17

Mutations

Mutations present another case of reuse - this time of our data. This case is even more critical because it can not just lead to code bloat, but data ingrity, tearing, and general application jankiness.

When we call our mutation method/endpoint, we need to ensure all uses of that data are updated. Otherwise we're stuck with the complexity, performance, and stuttery application jank of attempting to cascade endpoint refreshes.

Keep data consistent and fresh

Entities define our data model.

This enables a DRY storage pattern, which prevents 'data tearing' jank and improves performance.

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

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

pk() {
return `${this.id}`;
}
}

The pk() (primary key) method is used to build a lookup table. This is commonly known as data normalization. To avoid bugs, application jank and performance problems, it is critical to choose the right (normalized) state structure.

We can now bind our Entity to both our get endpoint and update endpoint, providing our runtime data integrity as well as TypeScript definitions.

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

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

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

Tell react to update

Just like setState(), we must make React aware of the any mutations so it can rerender.

Controller provides this functionality in a type-safe manner. Controller.fetch() lets us trigger mutations.

We can useController to access it in React components.

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

function ArticleEdit() {
const ctrl = useController();
const handleSubmit = data => ctrl.fetch(todoUpdate, { id }, data);
return <ArticleForm onSubmit={handleSubmit} />;
}
Tracking imperative loading/error state

useLoading() enhances async functions by tracking their loading and error states.

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

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

React 18 version with useTransition

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

function ArticleEdit() {
const ctrl = useController();
const [loading, startTransition] = useTransition();
const handleSubmit = data =>
startTransition(() => ctrl.fetch(todoUpdate, { id }, data));
return <ArticleForm onSubmit={handleSubmit} loading={loading} />;
}

More data modeling

What if our entity is not the top level item? Here we define the todoList endpoint with [Todo] as its schema. Schemas tell Reactive Data Client where to find the Entities. By placing inside a list, Reactive Data Client knows to expect a response where each item of the list is the entity specified.

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

export const getTodoList = new RestEndpoint({
urlPrefix: 'https://jsonplaceholder.typicode.com',
path: '/todos',
schema: [Todo],
});

Schemas also automatically infer and enforce the response type, ensuring the variable todos will be typed precisely.

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

export default function TodoList() {
const todos = useSuspense(getTodoList);

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

Now we've used our data model in three cases - getTodo, getTodoList and updateTodo. Data consistency (as well as referential equality) will be guaranteed between the endpoints, even after mutations occur.

Organizing Endpoints

At this point we've defined todoDetail, todoList and todoUpdate. You might have noticed that these endpoint definitions share some logic and information. For this reason Reactive Data Client encourages extracting shared logic among endpoints.

Resources are collections of endpoints that operate on the same data.

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

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

pk() {
return `${this.id}`;
}
}

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

Introduction to Resource

Resource Endpoints
// 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);

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

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

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

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

Zero delay mutations

Controller.fetch call the mutation endpoint, and update React based on the response. While useTransition improves the experience, the UI still ultimately waits on the fetch completion to update.

For many cases like toggling todo.completed, incrementing an upvote, or dragging and drop a frame this can be too slow!

We can optionally tell Reactive Data Client to perform the React renders immediately. To do this we'll need to specify how.

getOptimisticResponse is just like setState with an updater function. Using snap for access to the store to get the previous value, as well as the fetch arguments, we return the expected fetch response.

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

Reactive Data Client ensures data integrity against any possible networking failure or race condition, so don't worry about network failures, multiple mutation calls editing the same data, or other common problems in asynchronous programming.

Remotely triggered mutations

Sometimes data change is initiated remotely - either due to other users on the site, admins, etc. Declarative expiry policy controls allow tight control over updates due to fetching.

However, for data that changes frequently (like exchange price tickers, or live conversations) sometimes push-based protocols are used like Websockets or Server Sent Events. Reactive Data Client has a powerful middleware layer called Managers, which can be used to initiate data updates when receiving new data pushed from the server.

StreamManager
import type { Manager, Middleware } from '@data-client/core';
import type { EndpointInterface } from '@data-client/endpoint';

export default class StreamManager implements Manager {
protected declare middleware: Middleware;
protected declare evtSource: WebSocket | EventSource;
protected declare endpoints: Record<string, EndpointInterface>;

constructor(
evtSource: WebSocket | EventSource,
endpoints: Record<string, EndpointInterface>,
) {
this.evtSource = evtSource;
this.endpoints = endpoints;

this.middleware = controller => {
this.evtSource.onmessage = event => {
try {
const msg = JSON.parse(event.data);
if (msg.type in this.endpoints)
controller.setResponse(this.endpoints[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();
}

getMiddleware() {
return this.middleware;
}
}

If we don't want the full data stream, we can useSubscription() or useLive() to ensure we only listen to the data we care about.

Endpoints with pollFrequency allow reusing the existing HTTP endpoints, eliminating the need for additional websocket or SSE backends. Polling is globally orchestrated by the SubscriptionManager, so even with many components subscribed Reactive Data Client will never overfetch.

Debugging

redux-devtools

Add the Redux DevTools for chrome extension or firefox extension

Click the icon to open the inspector, which allows you to observe dispatched actions, their effect on the cache state as well as current cache state.

Mock data

Writing Fixtures is a standard format that can be used across all @data-client/test helpers as well as your own uses.

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 on GitHub

More Demos