Introduction
Rest Hooks is an asynchronous data framework for TypeScript and JavaScript. While it is completely protocol and platform agnostic, it is not a networking stack for things like minecraft game servers.
A good way to tell if this could be useful is if you use something similar to any of the following to build data-driven applications:
- API protocols like REST, GraphQL, gRPC, JSON:API
- Transport protocols like HTTP, WebSockets, local
- Async storage engines like IndexedDb, AsyncStorage
Rest Hooks focuses on solving the following challenges in a declarative composable manner
- Asynchronous behavior and race conditions
- Dynamic (changing) data consistency and integrity
- High performance at scale
Endpoint
Endpoints describe an asynchronous API.
These define both runtime
behaviors, as well as (optionally) typing
.
import { Endpoint } from '@rest-hooks/endpoint';
const fetchTodoDetail = ({ id }) =>
fetch(`https://jsonplaceholder.typicode.com/todos/${id}`).then(res =>
res.json(),
);
const todoDetail = new Endpoint(fetchTodoDetail);
import { Endpoint } from '@rest-hooks/endpoint';
interface Todo {
userId: number;
id: number;
title: string;
completed: boolean;
}
interface Params {
id: number;
}
const fetchTodoDetail = ({ id }: Params): Promise<Todo> =>
fetch(`https://jsonplaceholder.typicode.com/todos/${id}`).then(res =>
res.json(),
);
const todoDetail = new Endpoint(fetchTodoDetail);
By decoupling endpoint definitions from their usage, we are able to reuse them in many contexts.
- Easy reuse in different components eases colocating data dependencies
- Reuse with different hooks 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
Colocate data dependencies
Add one-line data hookup in the components that need it with useResource()
import { useResource } from 'rest-hooks';
export default function TodoDetail({ id }: { id: number }) {
const todo = useResource(todoDetail, { id });
return <div>{todo.title}</div>;
}
- Avoid prop drilling
- Data updates only re-render components that need to
Async Fallbacks with Boundaries
Unify and reuse loading and error fallbacks with Suspense and NetworkErrorBoundary
import { Suspense } from 'react';
import { NetworkErrorBoundary } from 'rest-hooks';
function App() {
return (
<Suspense fallback="loading">
<NetworkErrorBoundary>
<AnotherRoute />
<TodoDetail id={5} />
</NetworkErrorBoundary>
</Suspense>
);
}
Non-Suspense fallback handling
Mutations
todoUpdate
import { Endpoint } from '@rest-hooks/endpoint';
const fetchTodoUpdate = ({ id }, body) =>
fetch(`https://jsonplaceholder.typicode.com/todos/${id}`, {
method: 'PATCH',
body,
}).then(res => res.json());
const todoUpdate = new Endpoint(fetchTodoUpdate, { sideEffect: true });
import { Endpoint } from '@rest-hooks/endpoint';
interface Todo {
userId: number;
id: number;
title: string;
completed: boolean;
}
interface Params {
id: number;
}
const fetchTodoUpdate = ({ id }: Params, body: FormData): Promise<Todo> =>
fetch('https://jsonplaceholder.typicode.com/todos', {
method: 'PATCH',
body,
}).then(res => res.json());
const todoUpdate = new Endpoint(fetchTodoUpdate, { sideEffect: true });
Instead of just calling the todoUpdate
endpoint with our data, we want to ensure
all colocated usages of the todo being edited are updated. This avoid both the complexity and performance
problems of attempting to cascade endpoint refreshes.
useFetcher enhances our function, integrating the Rest Hooks store.
import { useFetcher } from 'rest-hooks';
const update = useFetcher(todoUpdate);
return <ArticleForm onSubmit={data => update({ id }, data)} />;
Tracking imperative loading/error
useLoading() enhances async functions by tracking their loading and error states.
import { useLoading } from '@rest-hooks/hooks';
const [update, loading, error] = useLoading(useFetcher(todoUpdate));
However, there is still one issue. Our todoUpdate
and todoDetail
endpoint are not aware of each other
so how can Rest Hooks know to update todoDetail with this data?
Entities
Adding Entities to our endpoint definition tells Rest Hooks how to extract and find a given piece of data no matter where it is used. The pk() (primary key) method is used as a key in a lookup table.
import { Entity } from '@rest-hooks/endpoint';
export class Todo extends Entity {
readonly userId: number = 0;
readonly id: number = 0;
readonly title: string = '';
readonly completed: boolean = false;
pk() {
return `${this.id}`;
}
}
import { Endpoint } from '@rest-hooks/endpoint';
interface Params {
id: number;
}
const fetchTodoDetail = ({ id }: Params) =>
fetch(`https://jsonplaceholder.typicode.com/todos/${id}`).then(res =>
res.json(),
);
const todoDetail = new Endpoint(fetchTodoDetail, {
schema: Todo,
sideEffect: true,
});
import { Endpoint } from '@rest-hooks/endpoint';
interface Params {
id: number;
}
const fetchTodoUpdate = ({ id }: Params, body: FormData) =>
fetch('https://jsonplaceholder.typicode.com/todos', {
method: 'PATCH',
body,
}).then(res => res.json());
const todoUpdate = new Endpoint(fetchTodoUpdate, {
schema: Todo,
sideEffect: true,
});
Schema
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 { Endpoint } from '@rest-hooks/endpoint';
const fetchTodoList = (params: any) =>
fetch(`https://jsonplaceholder.typicode.com/todos/`).then(res => res.json());
const todoList = new Endpoint(fetchTodoList, {
schema: [Todo],
sideEffect: true,
});
Schemas also automatically infer and enforce the response type, ensuring
the variable todos
will be typed precisely. If the API responds in another manner
the hook with throw instead, triggering the error fallback
specified in \<NetworkErrorBoundary />
import { useResource } from 'rest-hooks';
export default function TodoListComponent() {
const todos = useResource(todoList, {});
return (
<div>
{todos.map(todo => (
<TodoListItem key={todo.pk()} todo={todo} />
))}
</div>
);
}
This also guarantees data consistency (as well as referential equality) between todoList
and todoDetail
endpoints, as well
as any mutations that occur.
Optimistic Updates
By using the response of the mutation call to update the Rest Hooks store, we were able to keep our components updated automatically and only after one request.
However, after toggling todo.completed, this is just too slow! No worries, optimisticUpdate tells Rest Hooks what response it expects to receive from the mutation call, Rest Hooks can immediately update all components using the relevant entity.
const optimisticUpdate = (params: Params, body: FormData) => ({
id: params.id,
...body,
});
todoUpdate = todoUpdate.extend({
optimisticUpdate,
});
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.
todoUpdate
import { Endpoint } from '@rest-hooks/endpoint';
interface Params {
id: number;
}
const fetchTodoUpdate = ({ id }: Params, body: FormData) =>
fetch('https://jsonplaceholder.typicode.com/todos', {
method: 'PATCH',
body,
}).then(res => res.json());
const todoUpdate = new Endpoint(fetchTodoUpdate, {
sideEffect: true,
schema: Todo,
optimisticUpdate,
});
const optimisticUpdate = (params: Params, body: FormData) => ({
id: params.id,
...body,
});
Protocol specific patterns
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.
One common pattern is having endpoints Create Read Update Delete (CRUD) for a given resource. Using @rest-hooks/rest (docs) simplifies these patterns.
Instead of defining an Entity, we define a Resource. Resource
extends from Entity
, so we still need the pk()
definiton.
In addition, providing static urlRoot enable 6 Endpoints with easy logic sharing and overrides.
import { Resource } from '@rest-hooks/rest';
class TodoResource extends Resource {
readonly id: number = 0;
readonly userId: number = 0;
readonly title: string = '';
readonly completed: boolean = false;
static urlRoot = 'https://jsonplaceholder.typicode.com/todos';
pk() {
return `${this.id}`;
}
}
Usage
// read
// GET https://jsonplaceholder.typicode.com/todos/5
const todo = useResource(TodoResource.detail(), { id: 5 });
// GET https://jsonplaceholder.typicode.com/todos
const todos = useResource(TodoResource.list(), {});
// mutate
// POST https://jsonplaceholder.typicode.com/todos
const create = useFetcher(TodoResource.create());
create({}, { title: 'my todo' });
// PUT https://jsonplaceholder.typicode.com/todos/5
const update = useFetcher(TodoResource.update());
update({ id: 5 }, { title: 'my todo' });
// PATCH https://jsonplaceholder.typicode.com/todos/5
const partialUpdate = useFetcher(TodoResource.partialUpdate());
partialUpdate({ id: 5 }, { title: 'my todo' });
// DELETE https://jsonplaceholder.typicode.com/todos/5
const del = useFetcher(TodoResource.delete());
del({ id: 5 });
Demo
See this all in action in examples/todo-app