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 make it easy to share and reuse strongly typed APIs
Protocol implementations make definitions a breeze by building off the basic Endpoint. For protocols not shipped here, feel free to extend Endpoint directly.
- 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
}
}
`);
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
Bind the data where you need it with the one-line useSuspense()
import { useSuspense } from 'rest-hooks';
export default function TodoDetail({ id }: { id: number }) {
const todo = useSuspense(getTodo, { id });
return <div>{todo.title}</div>;
}
render(<TodoDetail id={1} />);
- 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 can also be used for certain cases in React 16 and 17
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.
useController gives us access to the Rest Hooks Controller, which is used to trigger imperative actions like fetch.
import { useController } from 'rest-hooks';
const { fetch } = useController();
return <ArticleForm onSubmit={data => fetch(todoUpdate, { 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 { fetch } = useController();
const [update, loading, error] = useLoading(
data => fetch(todoUpdate, { id }, data),
[fetch],
);
return <ArticleForm onSubmit={update} />;
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.
This enables a DRY storage pattern, which prevents 'data tearing' jank and improves performance.
- 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 { useSuspense } from 'rest-hooks';
export default function TodoListComponent() {
const todos = useSuspense(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, getOptimisticResponse 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 getOptimisticResponse = (
snap: SnapshotInterface,
params: Params,
body: FormData,
) => ({
id: params.id,
...body,
});
todoUpdate = todoUpdate.extend({
getOptimisticResponse,
});
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, SnapshotInterface } 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,
getOptimisticResponse,
});
const getOptimisticResponse = (
snap: SnapshotInterface,
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.
RestEndpoint extends Endpoint simplifying HTTP patterns.
createResource takes this one step further by creating 6 Endpoints with easy logic sharing and overrides.
import { Entity, createResource } from '@rest-hooks/rest';
class Todo extends Entity {
readonly id: number = 0;
readonly userId: number = 0;
readonly title: string = '';
readonly completed: boolean = false;
pk() {
return `${this.id}`;
}
}
const TodoResource = createResource({
urlPrefix: 'https://jsonplaceholder.typicode.com',
path: '/todos/:id',
});
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 controller = useController();
controller.fetch(TodoResource.create, { title: 'my todo' });
// PUT https://jsonplaceholder.typicode.com/todos/5
const controller = useController();
controller.fetch(TodoResource.update, { id: 5 }, { title: 'my todo' });
// PATCH https://jsonplaceholder.typicode.com/todos/5
const controller = useController();
controller.fetch(TodoResource.partialUpdate, { id: 5 }, { title: 'my todo' });
// DELETE https://jsonplaceholder.typicode.com/todos/5
const controller = useController();
controller.fetch(TodoResource.delete, { 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 } = useSuspense(userDetail, { name: 'Fong' });
@rest-hooks/img
A simple ArrayBuffer can be easily achieved using @rest-hooks/endpoint directly
import { Endpoint } from '@rest-hooks/endpoint';
export const getPhoto = new Endpoint(async ({ userId }: { userId: string }) => {
const response = await fetch(`/users/${userId}/photo`);
const photoArrayBuffer = await response.arrayBuffer();
return photoArrayBuffer;
});
@rest-hooks/img integrates images with Suspense as well as the render as you fetch pattern for improved user experience.
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()