api
api copied to clipboard
Simple data loading for React
@synvox/api
Simple HTTP calls in React using Suspense.
npm i @synvox/api axios
CodeSandbox
Features
- Wrapper around
axios. Pass in anaxiosinstance of your choosing - Small interface
useApia suspense compatible hook for loading dataapia wrapper around axiostouch(...keys: string[])to refetch queriesdefer<T>(() => T, defaultValue: T): {data: T, loading:boolean}to defer an HTTP callpreload(() => any): Promise<void>to preload an HTTP call
- Run any
GETrequest through Suspense - Refresh requests without flickering
- De-duplicates
GETrequests to the same url - Caches urls while they're in use and garbage collects them when they are not.
- Can be used in conditions and loops
- Easy integration with websockets and SSE for real-time apps
- Well tested and and written in Typescript
- Tiny
Basic Example
import { createApi } from '@synvox/api';
import axios from 'axios';
const { useApi } = createApi(
axios.create({
baseURL: 'https://your-api.com',
headers: {
'Authorization': 'Bearer your-token-here'
}
})
);
export useApi;
// then elsewhere:
import { useApi } from './api'
function Post({postId}) {
const api = useApi();
const user = api.users.me.get(); // GET https://your-api.com/users/me
const post = api.posts[postId].get(); // GET https://your-api.com/posts/{postId}
const comments = api.comments.get({postId: post.id}); // GET https://your-api.com/comments?post_id={postId}
const authorName = post.authorId === user.id
? 'You'
: api.users[post.authorId].get().name// GET https://your-api.com/users/{post.authorId}
return <>
<h2>{post.title} by {authorName}</h2>
<p>{post.body}</p>
<ul>
{comments.map(comment=><li key={comment.id}>{comment.body}</li>)}
</ul>
</>;
}
The useApi hook
useApi returns a Proxy that builds an axios request when you call it. For example:
import { createApi } from '@synvox/api';
import axios from 'axios';
const { useApi } = createApi(axios);
// in a component:
const api = useApi();
const users = api.users(); // calls GET /users
const notifications = api.notifications.get(); // calls GET /notifications, defaults to `get` when no method is specified.
const userId = 1;
const comments = api.users({ userId: 1 }); // calls GET /users?user_id=1
const projectId = 2;
const project = api.projects[projectId](); // calls GET /projects/2
const userProject = api.users[userId].projects[projectId]({ active: true }); // calls GET /users/1/projects/2?active=true
Calling api
api.path[urlParam](params: object, config?: AxiosConfig) as Type
// | | | |__ axios options like `data` and `headers`
// | | |__ query params (uses query-string under the hood so arrays work)
// | |__ url params
// \__ the url path
useApi and the laws of hooks
You cannot wrap a hook in a condition or use it in a loop, but the api object is not a hook, so feel free to use it wherever data is needed.
const api = useApi();
const users = shouldLoadUsers ? api.users() : [];
return (
<>
{users.map(user => (
<div key={user.id}>
{user.name}: {api.stars.count({ userId: user.id })}
</div>
))}
</>
);
Refetching
Call touch to refetch queries by url fragment(s).
import { createApi } from '@synvox/api';
import axios from 'axios';
const { useApi, touch } = createApi(axios);
// in a component
const api = useApi();
const [commentBody, setCommentBody] = useState('');
async function submit(e) {
e.preventDefault();
// notice you can specify a method when making a call
await api.comments.post(
{},
{
data: {
body: commentBody,
},
}
);
// when used outside a render phase, api returns an AxiosPromise
await touch('comments', 'users');
setCommentBody('');
}
return <form onSubmit={submit}>// Component stuff</form>;
The touch function will find all the used requests that contain the word(s) given to touch and run those requests again in the background, only updating the components when all the requests are completed. This helps a ton with flickering and race conditions.
Because touch is not a hook, it can be used outside a component in a websocket handler or a SSE listener to create real-time experiences.
import { touch } from './api';
const sse = new EventSource('/events');
sse.addEventListener('update', e => {
// assume e.data is {touches: ['messages', 'notifications']}
touch(...e.data.touches);
});
Using api outside a component
When the api object is used outside a component as its rendering, it will return an axios call to that url.
import { api } from './api';
export async function logout() {
// notice you can specify a method like `post` when making a call
await api.logout.post();
}
Preloading (and avoiding waterfall requests)
Suspense will wait for promises to fulfill before resuming a render which means requests are not loaded parallel. While this is fine for many components, you may want to start the loading of many requests at once. To do this call preload:
import { preload, useApi } from './api';
function Component() {
const api = useApi();
// use the same way you would in a render phase
preload(() => api.users());
preload(() => api.posts());
// suspend for /users
const users = api.users();
// suspend for /posts, but the promise for posts will have
// already been created in the preload call above.
const posts = api.posts();
return (
<nav>
<a
href="/tasks"
onMouseDown={() => {
// use preload in a handler if you want
preload(() => {
// works with multiple calls
const user = api.users.me();
const tasks = api.tasks({ userId: user.id });
});
}}
>
Tasks
</a>
</nav>
);
}
Deferring Requests (make request, but don't suspend)
If you need to make a request but need to defer until after the first render, then use defer:
import { defer } from '@synvox/api';
function Component() {
const api = useApi();
const { data: users, loading } = defer(() => api.users(), []);
if (loading) return <Spinner />;
return <UsersList users={users} />;
}
This still subscribes the component to updates from touch, request de-duplication, and garbage collection.
Binding Links
You can build graph-like structures with useApi by adding a modifier. Pass in a modifier to createApi to build custom link bindings:
// Transforms responses like {'@links': {comments: '/comments?post_id=123' }} into
// an object where data.comments will load /comments?post_id=123
function bindLinks(object: any, loadUrl: (url: string) => unknown) {
if (!object || typeof object !== 'object') return object;
const { '@links': links } = object;
if (!links) return object;
const returned: any = Array.isArray(object) ? [] : {};
for (let [key, value] of Object.entries(object)) {
if (value && typeof value === 'object') {
returned[key] = bindLinks(value, loadUrl);
} else returned[key] = value;
}
if (!links) return returned;
for (let [key, url] of Object.entries(links)) {
if (!object[key]) {
Object.defineProperty(returned, key, {
get() {
return loadUrl(url as string);
},
enumerable: false,
configurable: false,
});
}
}
return returned;
}
const { useApi } = createApi(axios, {
modifier: bindLinks,
});
Defining nested dependencies
Say you call /comments which returns Comment[] and want each Comment to be loaded into the cache individually so calling /comments/:id doesn't make another request. You can do this by setting a deduplication strategy.
// will update the cache for all all `{"@url": ...} objects
function deduplicationStrategy(item: any): { [key: string]: any } {
if (!item || typeof item !== 'object') return {};
if (Array.isArray(item))
return item
.map(deduplicationStrategy)
.reduce((a, b) => ({ ...a, ...b }), {});
const result: { [key: string]: any } = {};
for (let value of Object.values(item)) {
Object.assign(result, deduplicationStrategy(value));
}
if (item['@url']) {
result[item['@url']] = item;
}
return result;
}
const { useApi, api, touch, reset, preload } = createApi(axios, {
modifier: bindLinks,
deduplicationStrategy: (item: any) => {
const others = deduplicationStrategy(item);
return others;
},
});
Case Transformations
You can optionally specify a case transformation for request bodies, response bodies, and urls.
createApi(axios, {
requestCase: 'snake' | 'camel' | 'constant' | 'pascal' | 'kebab' | 'none',
responseCase: 'snake' | 'camel' | 'constant' | 'pascal' | 'kebab' | 'none',
urlCase: 'snake' | 'camel' | 'constant' | 'pascal' | 'kebab' | 'none',
});
Saving and Restoring
To save the cache call save:
const { save, restore } = createApi(axios);
localStorage.__cache = JSON.stringify(save());
To restore the cache call restore:
const { save, restore } = createApi(axios);
restore(window.data__from__SSR);
Retries
Set retryCount to specify how many times failing GET requests should be retried. Requests are delayed by 1s and double for each retry but will not delay longer than 30s. E.g. 1s, 2s, 4s, 8s, ..., 30s
Retrying only applies to GET requests called in a render.
createApi(axios, { retryCount: 10 });
Why not just a useEffect hook or Redux?
See Comparison
Obligatory Notice about Suspense for data loading
The React team has asked that we do not build on react-cache until it is stable, but that doesn't mean we can't experiment with an implementation of our own Suspense compatible cache until react-cache is stable.