The Relational Data Client
Rest Hooks 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.
We call Rest Hooks a Relational Data Client because 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 Rest Hooks 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, Rest Hooks' design is aimed at treating remote data like it is local. This means component logic should be no more complex than useState and setState.
- Write code faster: focus on your application, not complex state management
- Stop worrying about data bugs or application jank
- Deliver best application performance without extra work
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 '@rest-hooks/rest';
const getTodo = new RestEndpoint({
urlPrefix: 'https://jsonplaceholder.typicode.com',
path: '/todos/:id',
});
import { GQLEndpoint } from '@rest-hooks/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 '@rest-hooks/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. Rest Hooks guarantees global referential equality, data safety and performance.
Co-location also allows Server Side Rendering to incrementally stream HTML, greatly reducing TTFB. Rest Hooks 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 '@rest-hooks/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 '@rest-hooks/rest';
export class Todo extends Entity {
userId = 0;
id = 0;
title = '';
completed = false;
pk() {
return `${this.id}`;
}
}
import { GQLEntity } from '@rest-hooks/graphql';
export class Todo extends GQLEntity {
userId = 0;
id = 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 '@rest-hooks/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,
});
import { GQLEndpoint } from '@rest-hooks/graphql';
const gql = new GQLEndpoint('/');
export const getTodo = gql.query(
`query GetTodo($id: ID!) {
todo(id: $id) {
id
title
completed
}
}
`,
{ todo: Todo },
);
export const updateTodo = gql.mutation(
`mutation UpdateTodo($todo: Todo!) {
updateTodo(todo: $todo) {
id
title
completed
}
}`,
{ updateTodo: 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 '@rest-hooks/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 '@rest-hooks/react';
import { useLoading } from '@rest-hooks/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 '@rest-hooks/react';
import { useLoading } from '@rest-hooks/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 Rest Hooks where to find
the Entities. By placing inside a list, Rest Hooks knows to expect a response
where each item of the list is the entity specified.
import { RestEndpoint } from '@rest-hooks/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 '@rest-hooks/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 Rest Hooks
encourages extracting shared logic among endpoints.
Resources are collections of endpoints that operate on the same data.
import { Entity, createResource } from '@rest-hooks/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,
});
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.create, { 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 Rest Hooks 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,
};
},
});
Rest Hooks 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. Rest Hooks 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 '@rest-hooks/core';
import type { EndpointInterface } from '@rest-hooks/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 Rest Hooks 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 @rest-hooks/test
helpers as well as your own uses.
- Detail
- Update
- 404 error
- Interceptor
- Interceptor (stateful)
import type { Fixture } from '@rest-hooks/test';
import { getTodo } from './todo';
const todoDetailFixture: Fixture = {
endpoint: getTodo,
args: [{ id: 5 }] as const,
response: {
id: 5,
title: 'Star Rest Hooks on Github',
userId: 11,
completed: false,
},
};
import type { Fixture } from '@rest-hooks/test';
import { updateTodo } from './todo';
const todoUpdateFixture: Fixture = {
endpoint: updateTodo,
args: [{ id: 5 }, { completed: true }] as const,
response: {
id: 5,
title: 'Star Rest Hooks on Github',
userId: 11,
completed: true,
},
};
import type { Fixture } from '@rest-hooks/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 '@rest-hooks/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 '@rest-hooks/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 makeRenderRestHook()
- Test components with MockResolver and mockInitialState()
Demo
- Todo
- GitHub
- NextJS SSR