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.
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.
- Rest
- GraphQL
import { RestEndpoint } from '@data-client/rest';
const getTodo = new RestEndpoint({
urlPrefix: 'https://jsonplaceholder.typicode.com',
path: '/todos/:id',
});
import { GQLEndpoint } from '@data-client/graphql';
const gql = new GQLEndpoint('/');
export const getTodo = gql.query(`
query GetTodo($id: ID!) {
todo(id: $id) {
id
title
completed
}
}
`);
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>;
}
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.
- Rest
- GraphQL
import { Entity } from '@data-client/rest';
export class Todo extends Entity {
id = 0;
userId = 0;
title = '';
completed = false;
}
import { GQLEntity } from '@data-client/graphql';
export class Todo extends GQLEntity {
userId = 0;
title = '';
completed = false;
}
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.
- Rest
- GraphQL
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 };
import { GQLEndpoint } from '@data-client/graphql';
const gql = new GQLEndpoint('/');
const get = gql.query(
`query GetTodo($id: ID!) {
todo(id: $id) {
id
title
completed
}
}
`,
{ todo: Todo },
);
const update = gql.mutation(
`mutation UpdateTodo($todo: Todo!) {
updateTodo(todo: $todo) {
id
title
completed
}
}`,
{ updateTodo: Todo },
);
export const TodoResource = { get, update };
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.
- Rest
- GraphQL
import { useController } from '@data-client/react';
function ArticleEdit() {
const ctrl = useController();
const handleSubmit = data => ctrl.fetch(TodoResource.update, { id }, data);
return <ArticleForm onSubmit={handleSubmit} />;
}
import { useController } from '@data-client/react';
function ArticleEdit() {
const ctrl = useController();
const handleSubmit = data => ctrl.fetch(TodoResource.update, { id, ...data });
return <ArticleForm onSubmit={handleSubmit} />;
}
Tracking imperative loading/error state
useLoading() enhances async functions by tracking their loading and error states.
import { useController, useLoading } from '@data-client/react';
function ArticleEdit() {
const ctrl = useController();
const [handleSubmit, loading, error] = useLoading(
data => ctrl.fetch(TodoResource.update, { id }, data),
[ctrl],
);
return <ArticleForm onSubmit={handleSubmit} loading={loading} />;
}
More data modeling
What if our entity is not the top level item? Here we define the getList
endpoint with new schema.Collection([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, schema } from '@data-client/rest';
// get and update definitions omitted
const getList = new RestEndpoint({
urlPrefix: 'https://jsonplaceholder.typicode.com',
path: '/todos',
schema: new schema.Collection([Todo]),
searchParams: {} as { userId?: string | number } | undefined,
paginationField: 'page',
});
export default TodoResource = { getList, get, update };
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(TodoResource.getList);
return (
<div>
{todos.map(todo => (
<TodoListItem key={todo.pk()} todo={todo} />
))}
</div>
);
}
Now we've used our data model in three cases - TodoResource.get
, TodoResource.getList
and TodoResource.update
. 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 TodoResource.get
, TodoResource.getList
and TodoResource.update
. 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, 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',
});
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);
// 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 });
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.
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 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, 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 {
protected declare evtSource: WebSocket | EventSource;
protected declare entities: Record<string, typeof 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 = JSON.parse(event.data);
if (msg.type in this.endpoints)
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();
}
}
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
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.
- Detail
- Update
- 404 error
- Interceptor
- Interceptor (stateful)
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,
},
};
import type { Fixture } from '@data-client/test';
import { updateTodo } from './todo';
const todoUpdateFixture: Fixture = {
endpoint: updateTodo,
args: [{ id: 5 }, { completed: true }] as const,
response: {
id: 5,
title: 'Star Reactive Data Client on Github',
userId: 11,
completed: true,
},
};
import type { Fixture } from '@data-client/test';
import { getTodo } from './todo';
const todoDetail404Fixture: Fixture = {
endpoint: getTodo,
args: [{ id: 9001 }] as const,
response: { status: 404, response: 'Not found' },
error: true,
};
import type { Interceptor } from '@data-client/test';
const currentTimeInterceptor: Interceptor = {
endpoint: new RestEndpoint({
path: '/api/currentTime/:id',
}),
response({ id }) {
return {
id,
updatedAt: new Date().toISOString(),
};
},
delay: () => 150,
};
import type { Interceptor } from '@data-client/test';
const incrementInterceptor: Interceptor = {
endpoint: new RestEndpoint({
path: '/api/count/increment',
method: 'POST',
body: undefined,
}),
response() {
return {
count: (this.count = this.count + 1),
};
},
delay: () => 150,
};
- Mock data for storybook with MockResolver
- Test hooks with renderDataHook()
- Test components with MockResolver and mockInitialState()