Semantic response helpers for your remix app.
MIT License
Semantic response helpers for your Remix app.
remix-response
provides response helpers that wait on all promises to
resolve before serializing the response.
yarn add remix-response
import type { LoaderArgs } from "@remix-run/node";
import { ok } from 'remix-response';
const wait = (delay: number) => new Promise((r) => setTimeout(r, delay));
const fetchListings = (search: string) => wait(600).then(() => []);
const fetchRecommendations = (user: unknown) => wait(300).then(() => []);
export const loader = async ({ request, context }: LoaderArgs) => {
const listings = fetchListings(request.url);
const recommendations = fetchRecommendations(context.user);
return ok({
listings, // Promise<[]>
recommendations, // Promise<[]>
});
};
export default function MyRouteComponent() {
const data = useLoaderData<typeof loader>(); // { listings: [], recommendations: [] }
// ...
}
The simplest way fetch data in a remix loader is to use an async function and unwrap every promise with await.
import type { LoaderArgs } from "@remix-run/node";
import { json } from "@remix-run/node";
const wait = (delay: number) => new Promise((r) => setTimeout(r, delay));
const fetchListings = (search: string) => wait(600).then(() => []);
const fetchRecommendations = (user: unknown) => wait(300).then(() => []);
export const loader = async ({ request, context }: LoaderArgs) => {
const listings = await fetchListings(request.url);
const recommendations = await fetchRecommendations(context.user);
return json({
listings,
recommendations,
});
};
However, if we need to fetch data from multiple independent sources
this can slow down the loader response since fetchRecommendations
doesn't start until after the fetchListings
request has been
completed. A better approach would be to delay waiting until all the
fetchs have been initiated.
export const loader = async ({ request, context }: LoaderArgs) => {
- const listings = await fetchListings(request.url);
+ const listings = fetchListings(request.url);
- const recommendations = await fetchRecommendations(context.user);
+ const recommendations = fetchRecommendations(context.user);
return json({
- listings,
+ listings: await listings,
- recommendations,
+ recommendations: await recommendations,
});
};
This change improves the time it takes to run the loader function because now all the fetches are run in parallel and we only need to wait for the longest fetch to complete.
remix-response
can simplifiy things a bit further by automatically
awaiting any promises provided to the top level object before
serializing the response.
This is similar to the behavior of Promise.all
but it preserves the
object shape and keys similar to RSVP.hash
or bluebird's
Promise.props
.
- import { json } from "@remix-run/node";
+ import { ok } from 'remix-response';
export const loader = async ({ request, context }: LoaderArgs) => {
const listings = fetchListings(request.url);
const recommendations = fetchRecommendations(context.user);
- return json({
+ return ok({
- listings: await listings,
+ listings,
- recommendations: await recommendations,
+ recommendations,
});
};
When returning a response, if any of the promises reject the response
will have a 500 status code. The data object will contain all of the
properites with an object similar to Promise.allSettled
indicating
if the promises are fulfilled or rejected and the value
/reason
. This
object can be used in your ErrorBoundary
component to render the
appropriate error message.
import type { LoaderArgs } from "@remix-run/node";
import { ok } from 'remix-response';
const wait = (delay: number) => new Promise((r) => setTimeout(r, delay));
const fetchListings = (search: string) => wait(600).then(() => []);
const fetchRecommendations = (user: unknown) => wait(300).then(() => []);
export const loader = async ({ request, context }: LoaderArgs) => {
const listings = fetchListings(request.url);
const recommendations = fetchRecommendations(context.user);
return ok({
listings, // Promise<[]>
recommendations, // Promise<[]>
ohNo: Promise.reject('oops!'),
});
};
export function ErrorBoundary() {
const error = useRouteError();
// {
// status: 500,
// statusText: 'Server Error',
// data: {
// listings: { status: 'fulfilled', value: [] },
// recommendations: { status: 'fulfilled', value: [] },
// ohNo: { status: 'rejected', reason: 'oops' },
// }
// }
return (
<div>
<h1>
{error.status} {error.statusText}
</h1>
<pre>{JSON.stringify(error.data, null, 2)}</pre>
</div>
);
}
If a response is thrown in the loader this indicates an error. Thrown responses will always keep their original status even if a promise rejects. Unlike a returned response, thown responses always use a settled object format with the status and value/reason. This is to ensure the shape will always be consistent in the ErrorBoundary component.
import type { LoaderArgs } from "@remix-run/node";
import { notFound } from 'remix-response';
const wait = (delay: number) => new Promise((r) => setTimeout(r, delay));
const fetchListings = (search: string) => wait(600).then(() => []);
const fetchRecommendations = (user: unknown) => wait(300).then(() => []);
export const loader = async ({ request, context }: LoaderArgs) => {
const listings = fetchListings(request.url);
const recommendations = fetchRecommendations(context.user);
throw notFound({
listings, // Promise<[]>
recommendations, // Promise<[]>
});
};
export function ErrorBoundary() {
const error = useRouteError();
// {
// status: 404,
// statusText: 'Not Found',
// data: {
// listings: { status: 'fulfilled', value: [] },
// recommendations: { status: 'fulfilled', value: [] },
// }
// }
return null;
}
import { created } from 'remix-response';
export const action = async () => {
return created({
status: 'new',
id: Promise.resolve(1),
});
};
import { created } from 'remix-response';
export const action = async () => {
return noContent();
};
import { resetContent } from 'remix-response';
export const loader = async () => {
return resetContent({
form: {},
id: Promise.resolve(1),
});
};
import { partialContent } from 'remix-response';
export const loader = async () => {
return partialContent({
title: 'RFC 2616 - Hypertext Transfer Protocol -- HTTP/1.1',
id: Promise.resolve(2616),
});
};
import { movedPermanently } from 'remix-response';
export const loader = async () => {
return movedPermanently('https://www.example.com/');
};
import { found } from 'remix-response';
export const action = async () => {
return found('https://www.example.com/');
};
import { seeOther } from 'remix-response';
export const action = async () => {
return seeOther('https://www.example.com/');
};
import { notModified } from 'remix-response';
export const loader = async ({ request }: LoaderArgs) => {
if (request.headers.get('If-Modified-Since') === 'Wed, 21 Oct 2015 07:28:00 GMT') {
return notModified(request.url);
}
};
import { temporaryRedirect } from 'remix-response';
export const action = async () => {
return temporaryRedirect('https://www.example.com/');
};
import { permanentRedirect } from 'remix-response';
export const action = async () => {
return permanentRedirect('https://www.example.com/');
};
import type { ActionArgs } from "@remix-run/node";
import { badRequest } from 'remix-response';
export async function action({ request }: ActionArgs) {
return badRequest({
form: request.formData(),
errors: Promise.resolve({name: 'missing'}),
});
};
import type { ActionArgs } from "@remix-run/node";
import { unauthorized } from 'remix-response';
export async function action({ request }: ActionArgs) {
return unauthorized({
form: request.formData(),
errors: Promise.resolve({user: 'missing'}),
});
};
import type { ActionArgs } from "@remix-run/node";
import { forbidden } from 'remix-response';
export async function action({ request }: ActionArgs) {
return forbidden({
form: request.formData(),
errors: Promise.resolve({user: 'missing'}),
});
};
import { notFound } from 'remix-response';
export async function loader() {
return notFound({
recommendations: []
fromTheBlog: Promise.resolve([]),
});
};
import { methodNotAllowed } from 'remix-response';
export async function action() {
return methodNotAllowed({
allowedMethods: Promise.resolve(['GET', 'POST']),
});
};
import { notAcceptable } from 'remix-response';
export async function action() {
return notAcceptable({
allowedLanguage: Promise.resolve(['US_en', 'US_es']),
});
};
import { conflict } from 'remix-response';
export async function action() {
return conflict({
error: Promise.resolve({ id: 'duplicate id' }),
});
};
import { gone } from 'remix-response';
export async function action() {
return gone({
error: Promise.resolve('resource deleted'),
});
};
import { preconditionFailed } from 'remix-response';
export async function action() {
return preconditionFailed({
modifiedSince: Promise.resolve(Date.now()),
});
};
import { expectationFailed } from 'remix-response';
export async function action() {
return expectationFailed({
error: Promise.resolve('Content-Length is too large.'),
});
};
import { teapot } from 'remix-response';
export async function action() {
return teapot({
error: Promise.resolve('🚫☕'),
});
};
import { preconditionFailed } from 'remix-response';
export async function action() {
return preconditionFailed({
error: Promise.resolve('Missing If-Match header.'),
});
};
import { tooManyRequests } from 'remix-response';
export async function action() {
return tooManyRequests({
retryIn: Promise.resolve(5 * 60 * 1000),
});
};
import { serverError } from 'remix-response';
export async function loader() {
throw serverError({
error: Promise.resolve('Unable to load resouce.'),
});
};
import { notImplemented } from 'remix-response';
export async function loader() {
throw notImplemented({
error: Promise.resolve('Unable to load resouce.'),
}, {
headers: { 'Retry-After': 300 }
});
};
import { serviceUnavailable } from 'remix-response';
export async function loader() {
throw serviceUnavailable({
error: Promise.resolve('Unable to load resouce.'),
}, {
headers: { 'Retry-After': 300 }
});
};
import { ok } from 'remix-response';
export const loader = async () => {
return ok({
hello: 'world',
promise: Promise.resolve('result'),
});
};
import { created } from 'remix-response';
export const action = async () => {
return created({
status: 'new',
id: Promise.resolve(1),
});
};
Kind: global variable
Param | Description |
---|---|
data | A JavaScript object that will be serialized as JSON. |
init? | An optional RequestInit configuration object. |
import { created } from 'remix-response';
export const action = async () => {
return noContent();
};
Kind: global variable
Param | Description |
---|---|
init? | An optional RequestInit configuration object. |
import { resetContent } from 'remix-response';
export const loader = async () => {
return resetContent({
form: {},
id: Promise.resolve(1),
});
};
Kind: global variable
Param | Description |
---|---|
data | A JavaScript object that will be serialized as JSON. |
init? | An optional RequestInit configuration object. |
import { partialContent } from 'remix-response';
export const loader = async () => {
return partialContent({
title: 'RFC 2616 - Hypertext Transfer Protocol -- HTTP/1.1',
id: Promise.resolve(2616),
});
};
Kind: global variable
Param | Description |
---|---|
data | A JavaScript object that will be serialized as JSON. |
init? | An optional RequestInit configuration object. |
import { movedPermanently } from 'remix-response';
export const loader = async () => {
return movedPermanently('https://www.example.com/');
};
Kind: global variable
Param | Description |
---|---|
url | A url to redirect the request to |
import { found } from 'remix-response';
export const action = async () => {
return found('https://www.example.com/');
};
Kind: global variable
Param | Description |
---|---|
url | A url to redirect the request to |
import { seeOther } from 'remix-response';
export const action = async () => {
return seeOther('https://www.example.com/');
};
Kind: global variable
Param | Description |
---|---|
url | A url to redirect the request to |
import { notModified } from 'remix-response';
export const loader = async ({ request }: LoaderArgs) => {
if (request.headers.get('If-Modified-Since') === 'Wed, 21 Oct 2015 07:28:00 GMT') {
return notModified(request.url);
}
};
Kind: global variable
Param | Description |
---|---|
url | A url to redirect the request to |
import { temporaryRedirect } from 'remix-response';
export const action = async () => {
return temporaryRedirect('https://www.example.com/');
};
Kind: global variable
Param | Description |
---|---|
url | A url to redirect the request to |
import { permanentRedirect } from 'remix-response';
export const action = async () => {
return permanentRedirect('https://www.example.com/');
};
Kind: global variable
Param | Description |
---|---|
url | A url to redirect the request to |
import type { ActionArgs } from "@remix-run/node";
import { badRequest } from 'remix-response';
export async function action({ request }: ActionArgs) {
return badRequest({
form: request.formData(),
errors: Promise.resolve({name: 'missing'}),
});
};
Kind: global variable
Param | Description |
---|---|
data | A JavaScript object that will be serialized as JSON. |
init? | An optional RequestInit configuration object. |
import type { ActionArgs } from "@remix-run/node";
import { unauthorized } from 'remix-response';
export async function action({ request }: ActionArgs) {
return unauthorized({
form: request.formData(),
errors: Promise.resolve({user: 'missing'}),
});
};
Kind: global variable
Param | Description |
---|---|
data | A JavaScript object that will be serialized as JSON. |
init? | An optional RequestInit configuration object. |
import type { ActionArgs } from "@remix-run/node";
import { forbidden } from 'remix-response';
export async function action({ request }: ActionArgs) {
return forbidden({
form: request.formData(),
errors: Promise.resolve({user: 'missing'}),
});
};
Kind: global variable
Param | Description |
---|---|
data | A JavaScript object that will be serialized as JSON. |
init? | An optional RequestInit configuration object. |
import { notFound } from 'remix-response';
export async function loader() {
return notFound({
recommendations: []
fromTheBlog: Promise.resolve([]),
});
};
Kind: global variable
Param | Description |
---|---|
data | A JavaScript object that will be serialized as JSON. |
init? | An optional RequestInit configuration object. |
import { methodNotAllowed } from 'remix-response';
export async function action() {
return methodNotAllowed({
allowedMethods: Promise.resolve(['GET', 'POST']),
});
};
Kind: global variable
Param | Description |
---|---|
data | A JavaScript object that will be serialized as JSON. |
init? | An optional RequestInit configuration object. |
import { notAcceptable } from 'remix-response';
export async function action() {
return notAcceptable({
allowedLanguage: Promise.resolve(['US_en', 'US_es']),
});
};
Kind: global variable
Param | Description |
---|---|
data | A JavaScript object that will be serialized as JSON. |
init? | An optional RequestInit configuration object. |
import { conflict } from 'remix-response';
export async function action() {
return conflict({
error: Promise.resolve({ id: 'duplicate id' }),
});
};
Kind: global variable
Param | Description |
---|---|
data | A JavaScript object that will be serialized as JSON. |
init? | An optional RequestInit configuration object. |
import { gone } from 'remix-response';
export async function action() {
return gone({
error: Promise.resolve('resource deleted'),
});
};
Kind: global variable
Param | Description |
---|---|
data | A JavaScript object that will be serialized as JSON. |
init? | An optional RequestInit configuration object. |
import { preconditionFailed } from 'remix-response';
export async function action() {
return preconditionFailed({
modifiedSince: Promise.resolve(Date.now()),
});
};
Kind: global variable
Param | Description |
---|---|
data | A JavaScript object that will be serialized as JSON. |
init? | An optional RequestInit configuration object. |
import { expectationFailed } from 'remix-response';
export async function action() {
return expectationFailed({
error: Promise.resolve('Content-Length is too large.'),
});
};
Kind: global variable
Param | Description |
---|---|
data | A JavaScript object that will be serialized as JSON. |
init? | An optional RequestInit configuration object. |
import { teapot } from 'remix-response';
export async function action() {
return teapot({
error: Promise.resolve('🚫☕'),
});
};
Kind: global variable
Param | Description |
---|---|
data | A JavaScript object that will be serialized as JSON. |
init? | An optional RequestInit configuration object. |
import { preconditionFailed } from 'remix-response';
export async function action() {
return preconditionFailed({
error: Promise.resolve('Missing If-Match header.'),
});
};
Kind: global variable
Param | Description |
---|---|
data | A JavaScript object that will be serialized as JSON. |
init? | An optional RequestInit configuration object. |
import { tooManyRequests } from 'remix-response';
export async function action() {
return tooManyRequests({
retryIn: Promise.resolve(5 * 60 * 1000),
});
};
Kind: global variable
Param | Description |
---|---|
data | A JavaScript object that will be serialized as JSON. |
init? | An optional RequestInit configuration object. |
import { serverError } from 'remix-response';
export async function loader() {
throw serverError({
error: Promise.resolve('Unable to load resouce.'),
});
};
Kind: global variable
Param | Description |
---|---|
data | A JavaScript object that will be serialized as JSON. |
init? | An optional RequestInit configuration object. |
import { notImplemented } from 'remix-response';
export async function loader() {
throw notImplemented({
error: Promise.resolve('Unable to load resouce.'),
}, {
headers: { 'Retry-After': 300 }
});
};
Kind: global variable
Param | Description |
---|---|
data | A JavaScript object that will be serialized as JSON. |
init? | An optional RequestInit configuration object. |
import { serviceUnavailable } from 'remix-response';
export async function loader() {
throw serviceUnavailable({
error: Promise.resolve('Unable to load resouce.'),
}, {
headers: { 'Retry-After': 300 }
});
};
Kind: global variable
Param | Description |
---|---|
data | A JavaScript object that will be serialized as JSON. |
init? | An optional RequestInit configuration object. |
import { ok } from 'remix-response';
export const loader = async () => {
return ok({
hello: 'world',
promise: Promise.resolve('result'),
});
};
Kind: global constant
Param | Description |
---|---|
data | A JavaScript object that will be serialized as JSON. |
init? | An optional RequestInit configuration object. |