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
- Global consistency and integrity of dynamic data
- High performance at scale
Endpoint
Endpoints describe an asynchronous API.
These define both runtime
behaviors, as well as (optionally) typing
.
- TypeScript
- JavaScript
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);
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);
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 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
Co-locate 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
- TypeScript
- JavaScript
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 });
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 });
Instead of just calling the todoUpdate
endpoint with our data, we want to ensure
all co-located 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 state
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.
- Entity
- todoDetail
- todoUpdate
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.
@rest-hooks/rest
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}`;
}
}
Resource Endpoints
// 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 });
@rest-hooks/graphql
GraphQL support ships in the @rest-hooks/graphql (docs) package.
import { GQLEntity, GQLEndpoint } from '@rest-hooks/graphql';
class User extends GQLEntity {
readonly name: string = '';
readonly email: string = '';
}
const gql = new GQLEndpoint('https://nosy-baritone.glitch.me');
const userDetail = gql.query(
`query UserDetail($name: String!) {
user(name: $name) {
id
name
email
}
}`,
{ user: User },
);
const { user } = useResource(userDetail, { name: 'Fong' });
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 FixtureEndpoint
s is a standard format that can be used across all @rest-hooks/test
helpers as well as your own uses.
- Detail
- Update
- 404 error
import type { FixtureEndpoint } from '@rest-hooks/test';
import { todoDetail } from './todo';
const todoDetailFixture: FixtureEndpoint = {
endpoint: todoDetail,
args: [{ id: 5 }] as const,
response: {
id: 5,
title: 'Star Rest Hooks on Github',
userId: 11,
completed: false,
},
};
import type { FixtureEndpoint } from '@rest-hooks/test';
import { todoUpdate } from './todo';
const todoUpdateFixture: FixtureEndpoint = {
endpoint: todoUpdate,
args: [{ id: 5 }, { completed: true }] as const,
response: {
id: 5,
title: 'Star Rest Hooks on Github',
userId: 11,
completed: true,
},
};
import type { FixtureEndpoint } from '@rest-hooks/test';
import { todoDetail } from './todo';
const todoDetail404Fixture: FixtureEndpoint = {
endpoint: todoDetail,
args: [{ id: 9001 }] as const,
response: { status: 404, response: 'Not found' },
error: true,
};
- Mock data for storybook with MockResolver
- Test hooks with makeRenderRestHook()
- Test components with MockResolver and mockInitialState()
Demo
See this all in action in examples/todo-app
Or a github api demo