Remix Utils
This package contains simple utility functions to use with Remix.run.
Installation
npm install remix-utils
API Reference
promiseHash
The promiseHash
function is not directly related to Remix but it's a useful function when working with loaders and actions.
This function is an object version of Promise.all
which lets you pass an object with promises and get an object with the same keys with the resolved values.
export async function loader({ request }: LoaderArgs) {
return json(
await promiseHash({
user: getUser(request),
posts: getPosts(request),
})
);
}
You can use nested promiseHash
to get a nested object with resolved values.
export async function loader({ request }: LoaderArgs) {
return json(
await promiseHash({
user: getUser(request),
posts: promiseHash({
list: getPosts(request),
comments: promiseHash({
list: getComments(request),
likes: getLikes(request),
}),
}),
})
);
}
timeout
The timeout
function lets you attach a timeout to any promise, if the promise doesn't resolve or reject before the timeout, it will reject with a TimeoutError
.
try {
let result = await timeout(fetch("https://example.com"), { ms: 100 });
} catch (error) {
if (error instanceof TimeoutError) {
}
}
Here the fetch needs to happen in less than 100ms, otherwise it will throw a TimeoutError
.
If the promise is cancellable with an AbortSignal you can pass the AbortController to the timeout
function.
try {
let controller = new AbortController();
let result = await timeout(
fetch("https://example.com", { signal: controller.signal }),
{ ms: 100, controller }
);
} catch (error) {
if (error instanceof TimeoutError) {
}
}
Here after 100ms, timeout
will call controller.abort()
which will mark the controller.signal
as aborted.
cacheAssets
Note
This can only be run inside entry.client
.
This function lets you easily cache inside the browser's Cache Storage every JS file built by Remix.
To use it, open your entry.client
file and add this:
import { cacheAssets } from "remix-utils";
cacheAssets().catch((error) => {
});
The function receives an optional options object with two options:
cacheName
is the name of the Cache object to use, the default value is assets
.buildPath
is the pathname prefix for all Remix built assets, the default value is /build/
which is the default build path of Remix itself.
It's important that if you changed your build path in remix.config.js
you pass the same value to cacheAssets
or it will not find your JS files.
The cacheName
can be left as is unless you're adding a Service Worker to your app and want to share the cache.
cacheAssests({ cacheName: "assets", buildPath: "/build/" }).catch((error) => {
});
ClientOnly
The ClientOnly component lets you render the children element only on the client-side, avoiding rendering it the server-side.
Note
If you're using React 18 and a streaming server rendering API (eg. renderToPipeableStream
) you probably want to use a <Suspense>
boundary instead.
export default function Component() {
return (
<Suspense fallback={<SimplerStaticVersion />}>
<ComplexComponentNeedingBrowserEnvironment />
</Suspense>
);
}
See "Providing a fallback for server errors and server-only content" in the React Suspense docs.
You can provide a fallback component to be used on SSR, and while optional, it's highly recommended to provide one to avoid content layout shift issues.
import { ClientOnly } from "remix-utils";
export default function Component() {
return (
<ClientOnly fallback={<SimplerStaticVersion />}>
{() => <ComplexComponentNeedingBrowserEnvironment />}
</ClientOnly>
);
}
This component is handy when you have some complex component that needs a browser environment to work, like a chart or a map. This way, you can avoid rendering it server-side and instead use a simpler static version like an SVG or even a loading UI.
The rendering flow will be:
- SSR: Always render the fallback.
- CSR First Render: Always render the fallback.
- CSR Update: Update to render the actual component.
- CSR Future Renders: Always render the actual component, don't bother to render the fallback.
This component uses the useHydrated
hook internally.
ServerOnly
The ServerOnly component is the opposite of the ClientOnly component, it lets you render the children element only on the server-side, avoiding rendering it the client-side.
You can provide a fallback component to be used on CSR, and while optional, it's highly recommended to provide one to avoid content layout shift issues, unless you only render visually hidden elements.
import { ServerOnly } from "remix-utils";
export default function Component() {
return (
<ServerOnly fallback={<ComplexComponentNeedingBrowserEnvironment />}>
{() => <SimplerStaticVersion />}
</ServerOnly>
);
}
This component is handy to render some content only on the server-side, like a hidden input you can later use to know if JS has loaded.
Consider it like the <noscript>
HTML tag but it can work even if JS failed to load but it's enabled on the browser.
The rendering flow will be:
- SSR: Always render the children.
- CSR First Render: Always render the children.
- CSR Update: Update to render the fallback component (if defined).
- CSR Future Renders: Always render the fallback component, don't bother to render the children.
This component uses the useHydrated
hook internally.
CORS
The CORS function let you implement CORS headers on your loaders and actions so you can use them as an API for other client-side applications.
There are two main ways to use the cors
function.
- Use it on each loader/action where you want to enable it.
- Use it globally on entry.server handleRequest and handleDataRequest export.
If you want to use it on every loader/action, you can do it like this:
export async function loader({ request }: LoaderArgs) {
let data = await getData(request);
let response = json<LoaderData>(data);
return await cors(request, response);
}
You could also do the json
and cors
call in one line.
export async function loader({ request }: LoaderArgs) {
let data = await getData(request);
return await cors(request, json<LoaderData>(data));
}
And because cors
mutates the response, you can also call it and later return.
export async function loader({ request }: LoaderArgs) {
let data = await getData(request);
let response = json<LoaderData>(data);
await cors(request, response);
return response;
}
If you want to setup it globally once, you can do it like this in entry.server
const ABORT_DELAY = 5000;
export default function handleRequest(
request: Request,
responseStatusCode: number,
responseHeaders: Headers,
remixContext: EntryContext
) {
let callbackName = isbot(request.headers.get("user-agent"))
? "onAllReady"
: "onShellReady";
return new Promise((resolve, reject) => {
let didError = false;
let { pipe, abort } = renderToPipeableStream(
<RemixServer context={remixContext} url={request.url} />,
{
[callbackName]: () => {
let body = new PassThrough();
responseHeaders.set("Content-Type", "text/html");
cors(
request,
new Response(body, {
headers: responseHeaders,
status: didError ? 500 : responseStatusCode,
})
).then((response) => {
resolve(response);
});
pipe(body);
},
onShellError: (err: unknown) => {
reject(err);
},
onError: (error: unknown) => {
didError = true;
console.error(error);
},
}
);
setTimeout(abort, ABORT_DELAY);
});
}
export let handleDataRequest: HandleDataRequestFunction = async (
response,
{ request }
) => {
return await cors(request, response);
};
Options
Additionally, the cors
function accepts a options
object as a third optional argument. These are the options.
origin
: Configures the Access-Control-Allow-Origin CORS header.
Possible values are:
true
: Enable CORS for any origin (same as "*")false
: Don't setup CORSstring
: Set to a specific origin, if set to "*" it will allow any originRegExp
: Set to a RegExp to match against the originArray<string | RegExp>
: Set to an array of origins to match against the
string or RegExpFunction
: Set to a function that will be called with the request origin
and should return a boolean indicating if the origin is allowed or not.
The default value is true
.
methods
: Configures the Access-Control-Allow-Methods CORS header.
The default value is ["GET", "HEAD", "PUT", "PATCH", "POST", "DELETE"]
.allowedHeaders
: Configures the Access-Control-Allow-Headers CORS header.exposedHeaders
: Configures the Access-Control-Expose-Headers CORS header.credentials
: Configures the Access-Control-Allow-Credentials CORS header.maxAge
: Configures the Access-Control-Max-Age CORS header.
CSRF
The CSRF related functions let you implement CSRF protection on your application.
This part of Remix Utils needs React and server-side code.
First create a new CSRF instance.
import { CSRF } from "remix-utils";
import { createCookie } from "@remix-run/node";
export const cookie = createCookie("csrf", {
path: "/",
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
secrets: ["s3cr3t"],
});
export const csrf = new CSRF({
cookie,
formDataKey: "csrf",
secret: "s3cr3t",
});
Then you can use csrf
to generate a new token.
import { csrf } from "~/utils/csrf.server";
export async function loader({ request }: LoaderArgs) {
let token = csrf.generate();
}
You can customize the token size by passing the byte size, the default one is 32 bytes which will give you a string with a length of 43 after encoding.
let token = csrf.generate(64);
You will need to save this token in a cookie and also return it from the loader. For convenience, you can use the CSRF#commitToken
helper.
import { csrf } from "~/utils/csrf.server";
export async function loader({ request }: LoaderArgs) {
let [token, cookieHeader] = await csrf.commitToken();
return json({ token }, { headers: { "set-cookie": cookieHeader } });
}
Note: You could do this on any route, but I recommend you to do it on the root
loader.
Now that you returned the token and set it in a cookie, you can use the AuthenticityTokenProvider
component to provide the token to your React components.
let { csrf } = useLoaderData<LoaderData>();
return (
<AuthenticityTokenProvider token={csrf}>
<Outlet />
</AuthenticityTokenProvider>
);
Render it in your root
component and wrap the Outlet
with it.
When you create a form in some route, you can use the AuthenticityTokenInput
component to add the authenticity token to the form.
import { Form } from "@remix-run/react";
import { AuthenticityTokenInput } from "remix-utils";
export default function Component() {
return (
<Form method="post">
<AuthenticityTokenInput />
<input type="text" name="something" />
</Form>
);
}
Note that the authenticity token is only really needed for a form that mutates the data somehow. If you have a search form making a GET request, you don't need to add the authenticity token there.
This AuthenticityTokenInput
will get the authenticity token from the AuthenticityTokenProvider
component and add it to the form as the value of a hidden input with the name csrf
. You can customize the field name using the name
prop.
<AuthenticityTokenInput name="customName" />
You should only customize the name if you also changed it on createAuthenticityToken
.
If you need to use useFetcher
(or useSubmit
) instead of Form
you can also get the authenticity token with the useAuthenticityToken
hook.
import { useFetcher } from "@remix-run/react";
import { useAuthenticityToken } from "remix-utils";
export function useMarkAsRead() {
let fetcher = useFetcher();
let csrf = useAuthenticityToken();
return function submit(data) {
fetcher.submit(
{ csrf, ...data },
{ action: "/api/mark-as-read", method: "post" }
);
};
}
Finally, you need to validate the authenticity token in the action that received the request.
import { CSRFError, redirectBack } from "remix-utils";
import { csrf } from "~/utils/csrf.server";
export async function action({ request }: ActionArgs) {
try {
await csrf.validate(request);
} catch (error) {
if (error instanceof CSRFError) {
}
}
return redirectBack(request, { fallback: "/fallback" });
}
If you need to parse the body as FormData yourself (e.g. to support file uploads) you can also call CSRF#validate
with the FormData and Headers objects.
let formData = await parseMultiPartFormData(request);
try {
await csrf.validate(formData, request.headers);
} catch (error) {
}
Warning: If you call CSRF#validate
with the request instance, but you already read its body, it will throw an error.
In case the CSRF validation fails, it will throw a CSRFError
which can be used to correctly identify it against other possible errors that may get thrown.
The list of possible error messages are:
missing_token_in_cookie
: The request is missing the CSRF token in the cookie.invalid_token_in_cookie
: The CSRF token is not valid (is not a string).tampered_token_in_cookie
: The CSRF token doesn't match the signature.missing_token_in_body
: The request is missing the CSRF token in the body (FormData).mismatched_token
: The CSRF token in the cookie and the body don't match.
You can use error.code
to check one of the error codes above, and error.message
to get a human friendly description.
Warning: Don't send those error messages to the end-user, they are meant to be used for debugging purposes only.
ExternalScripts
If you need to load different external scripts on certain routes, you can use the ExternalScripts
component together with the ExternalScriptsFunction
type.
In the route you want to load the script add a handle
export with a scripts
method, this method should implement the ExternalScriptsFunction
type.
let scripts: ExternalScriptsFunction<SerializeFrom<typeof loader>> = ({
id,
data,
params,
matches,
location,
parentsData,
}) => {
return [
{
src: "https://code.jquery.com/jquery-3.6.0.min.js",
integrity: "sha256-/xUj+3OJU5yExlq6GSYGSHk7tPXikynS7ogEvDej/m4=",
crossOrigin: "anonymous",
},
];
};
export let handle = { scripts };
Then, in the root route, add the ExternalScripts
component together with the Remix's Scripts component, usually inside a Document component.
import { Links, LiveReload, Meta, Scripts, ScrollRestoration } from "remix";
import { ExternalScripts } from "remix-utils";
type Props = { children: React.ReactNode; title?: string };
export function Document({ children, title }: Props) {
return (
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
{title ? <title>{title}</title> : null}
<Meta />
<Links />
</head>
<body>
{children}
<ScrollRestoration />
<ExternalScripts />
<Scripts />
<LiveReload />
</body>
</html>
);
}
Now, any script you defined in the ScriptsFunction will be added to the HTML together with a <link rel="preload">
before it.
Tip: You could use it together with useShouldHydrate to disable Remix scripts in certain routes but still load scripts for analytics or small features that need JS but don't need the full app JS to be enabled.
useGlobalNavigationState
This hook allows you to read the value of transition.state
, every fetcher.state
in the app, and revalidator.state
.
import { useGlobalNavigationState } from "remix-utils";
export function GlobalPendingUI() {
let states = useGlobalNavigationState();
if (state.includes("loading")) {
}
if (state.includes("submitting")) {
}
}
The return value of useGlobalNavigationState
can be "idle"
, "loading"
or "submitting"
Note This is used by the hooks below to determine if the app is loading, submitting or both (pending).
useGlobalPendingState
This hook lets you know if the global navigation, if one of any active fetchers is either loading or submitting, or if the revalidator is running.
import { useGlobalPendingState } from "remix-utils";
export function GlobalPendingUI() {
let globalState = useGlobalPendingState();
if (globalState === "idle") return null;
return <Spinner />;
}
The return value of useGlobalPendingState
is either "idle"
or "pending"
.
Note: This hook combines the useGlobalSubmittingState
and useGlobalLoadingState
hooks to determine if the app is pending.
Note: The pending
state is a combination of the loading
and submitting
states introduced by this hook.
useGlobalSubmittingState
This hook lets you know if the global transition or if one of any active fetchers is submitting.
import { useGlobalSubmittingState } from "remix-utils";
export function GlobalPendingUI() {
let globalState = useGlobalSubmittingState();
if (globalState === "idle") return null;
return <Spinner />;
}
The return value of useGlobalSubmittingState
is either "idle"
or "submitting"
.
useGlobalLoadingState
This hook lets you know if the global transition, if one of any active fetchers is loading, or if the revalidator is running
import { useGlobalLoadingState } from "remix-utils";
export function GlobalPendingUI() {
let globalState = useGlobalLoadingState();
if (globalState === "idle") return null;
return <Spinner />;
}
The return value of useGlobalLoadingState
is either "idle"
or "loading"
.
useHydrated
This hook lets you detect if your component is already hydrated. This means the JS for the element loaded client-side and React is running.
With useHydrated, you can render different things on the server and client while ensuring the hydration will not have a mismatched HTML.
import { useHydrated } from "remix-utils";
export function Component() {
let isHydrated = useHydrated();
if (isHydrated) {
return <ClientOnlyComponent />;
}
return <ServerFallback />;
}
When doing SSR, the value of isHydrated
will always be false
. The first client-side render isHydrated
will still be false, and then it will change to true
.
After the first client-side render, future components rendered calling this hook will receive true
as the value of isHydrated
. This way, your server fallback UI will never be rendered on a route transition.
useLocales
This hooks lets you get the locales returned by the root loader. It follows a simple convention, your root loader return value should be an objet with the key locales
.
You can combine it with getClientLocal
to get the locales on the root loader and return that. The return value of useLocales
is a Locales
type which is string | string[] | undefined
.
export async function loader({ request }: LoaderArgs) {
let locales = getClientLocales(request);
return json({ locales });
}
export default function Component() {
let locales = useLocales();
let date = new Date();
let dateTime = date.toISOString;
let formattedDate = date.toLocaleDateString(locales, options);
return <time dateTime={dateTime}>{formattedDate}</time>;
}
The return type of useLocales
is ready to be used with the Intl API.
useShouldHydrate
If you are building a Remix application where most routes are static, and you want to avoid loading client-side JS, you can use this hook, plus some conventions, to detect if one or more active routes needs JS and only render the Scripts component in that case.
In your document component, you can call this hook to dynamically render the Scripts component if needed.
import type { ReactNode } from "react";
import { Links, LiveReload, Meta, Scripts } from "@remix-run/react";
import { useShouldHydrate } from "remix-utils";
interface DocumentProps {
children: ReactNode;
title?: string;
}
export function Document({ children, title }: DocumentProps) {
let shouldHydrate = useShouldHydrate();
return (
<html lang="en">
<head>
<meta charSet="utf-8" />
<link rel="icon" href="/favicon.png" type="image/png" />
{title ? <title>{title}</title> : null}
<Meta />
<Links />
</head>
<body>
{children}
{shouldHydrate && <Scripts />}
<LiveReload />
</body>
</html>
);
}
Now, you can export a handle
object with the hydrate
property as true
in any route module.
export let handle = { hydrate: true };
This will mark the route as requiring JS hydration.
In some cases, a route may need JS based on the data the loader returned. For example, if you have a component to purchase a product, but only authenticated users can see it, you don't need JS until the user is authenticated. In that case, you can make hydrate
be a function receiving your loader data.
export let handle = {
hydrate(data: LoaderData) {
return data.user.isAuthenticated;
},
};
The useShouldHydrate
hook will detect hydrate
as a function and call it using the route data.
getClientIPAddress
This function receives a Request or Headers objects and will try to get the IP address of the client (the user) who originated the request.
export async function loader({ request }: LoaderArgs) {
let ipAddress = getClientIPAddress(request);
let ipAddress = getClientIPAddress(request.headers);
}
If it can't find he ipAddress the return value will be null
. Remember to check if it was able to find it before using it.
The function uses the following list of headers, in order of preference:
- X-Client-IP
- X-Forwarded-For
- Fly-Client-IP
- CF-Connecting-IP
- Fastly-Client-Ip
- True-Client-Ip
- X-Real-IP
- X-Cluster-Client-IP
- X-Forwarded
- Forwarded-For
- Forwarded
- DO-Connecting-IP
- oxygen-buyer-ip
When a header is found that contains a valid IP address, it will return without checking the other headers.
getClientLocales
This function let you get the locales of the client (the user) who originated the request.
export async function loader({ request }: LoaderArgs) {
let locales = getClientLocales(request);
let locales = getClientLocales(request.headers);
}
The return value is a Locales type, which is string | string[] | undefined
.
The returned locales can be directly used on the Intl API when formatting dates, numbers, etc.
import { getClientLocales } from "remix-utils";
export async function loader({ request }: LoaderArgs) {
let locales = getClientLocales(request);
let nowDate = new Date();
let formatter = new Intl.DateTimeFormat(locales, {
year: "numeric",
month: "long",
day: "numeric",
});
return json({ now: formatter.format(nowDate) });
}
The value could also be returned by the loader and used on the UI to ensure the user's locales is used on both server and client formatted dates.
isPrefetch
This function let you identify if a request was created because of a prefetch triggered by using <Link prefetch="intent">
or <Link prefetch="render">
.
This will let you implement a short cache only for prefetch requests so you avoid the double data request.
export async function loader({ request }: LoaderArgs) {
let data = await getData(request);
let headers = new Headers();
if (isPrefetch(request)) {
headers.set("Cache-Control", "private, max-age=5, smax-age=0");
}
return json(data, { headers });
}
Responses
Redirect Back
This function is a wrapper of the redirect
helper from Remix. Unlike Remix's version, this one receives the whole request object as the first value and an object with the response init and a fallback URL.
The response created with this function will have the Location
header pointing to the Referer
header from the request, or if not available, the fallback URL provided in the second argument.
import { redirectBack } from "remix-utils";
export async function action({ request }: ActionArgs) {
return redirectBack(request, { fallback: "/" });
}
This helper is most useful when used in a generic action to send the user to the same URL it was before.
Created
Helper function to create a Created (201) response with a JSON body.
import { created } from "remix-utils";
export async function action({ request }: ActionArgs) {
let result = await doSomething(request);
return created(result);
}
Bad Request
Helper function to create a Bad Request (400) response with a JSON body.
import { badRequest } from "remix-utils";
export async function action() {
throw badRequest({ message: "You forgot something in the form." });
}
Unauthorized
Helper function to create an Unauthorized (401) response with a JSON body.
import { unauthorized } from "remix-utils";
export async function loader() {
throw unauthorized({ message: "You need to login." });
}
Forbidden
Helper function to create a Forbidden (403) response with a JSON body.
import { forbidden } from "remix-utils";
export async function loader() {
throw forbidden({ message: "You don't have access for this." });
}
Not Found
Helper function to create a Not Found (404) response with a JSON body.
import { notFound } from "remix-utils";
export async function loader() {
throw notFound({ message: "This doesn't exist." });
}
Unprocessable Entity
Helper function to create an Unprocessable Entity (422) response with a JSON body.
import { unprocessableEntity } from "remix-utils";
export async function loader() {
throw unprocessableEntity({ message: "This doesn't exists." });
}
This is used by the CSRF validation. You probably don't want to use it directly.
Server Error
Helper function to create a Server Error (500) response with a JSON body.
import { serverError } from "remix-utils";
export async function loader() {
throw serverError({ message: "Something unexpected happened." });
}
Not Modified
Helper function to create a Not Modified (304) response without a body and any header.
export async function loader({ request }: LoaderArgs) {
return notModified();
}
JavaScript
Helper function to create a JavaScript file response with any header.
This is useful to create JS files based on data inside a Resource Route.
export async function loader({ request }: LoaderArgs) {
return javascript("console.log('Hello World')");
}
Stylesheet
Helper function to create a CSS file response with any header.
This is useful to create CSS files based on data inside a Resource Route.
export async function loader({ request }: LoaderArgs) {
return stylesheet("body { color: red; }");
}
PDF
Helper function to create a PDF file response with any header.
This is useful to create PDF files based on data inside a Resource Route.
export async function loader({ request }: LoaderArgs) {
return pdf(await generatePDF(request.formData()));
}
HTML
Helper function to create a HTML file response with any header.
This is useful to create HTML files based on data inside a Resource Route.
export async function loader({ request }: LoaderArgs) {
return html("<h1>Hello World</h1>");
}
XML
Helper function to create a XML file response with any header.
This is useful to create XML files based on data inside a Resource Route.
export async function loader({ request }: LoaderArgs) {
return xml("<?xml version='1.0'?><catalog></catalog>");
}
TXT
Helper function to create a TXT file response with any header.
This is useful to create TXT files based on data inside a Resource Route.
export async function loader({ request }: LoaderArgs) {
return txt(`
User-agent: *
Allow: /
`);
}
Typed Cookies
Cookie objects in Remix allows any type, the typed cookies from Remix Utils lets you use Zod to parse the cookie values and ensure they conform to a schema.
import { createCookie } from "@remix-run/node";
import { createTypedCookie } from "remix-utils";
import { z } from "zod";
let cookie = createCookie("returnTo", cookieOptions);
let schema = z.string().url().nullable();
let typedCookie = createTypedCookie({ cookie, schema });
let returnTo = await typedCookie.parse(request.headers.get("Cookie"));
await typedCookie.serialize("a random string that's not a URL");
await typedCookie.serialize(123);
You could also use typed cookies with any sessionStorage mechanism from Remix.
let cookie = createCookie("session", cookieOptions);
let schema = z.object({ token: z.string() }).nullable();
let sessionStorage = createCookieSessionStorage({
cookie: createTypedCookie({ cookie, schema }),
});
let session = sessionStorage.getSession(request.headers.get("Cookie"));
session.unset("token");
await sessionStorage.commitSession(session);
Now Zod will ensure the data you try to save to the session is valid removing any extra field and throwing if you don't set the correct data in the session.
Note
The session object is not really typed so doing session.get will not return the correct type, you can do schema.parse(session.data)
to get the typed version of the session data.
You can also use async refinements in your schemas because typed cookies uses parseAsync method from Zod.
let cookie = createCookie("session", cookieOptions);
let schema = z
.object({
token: z.string().refine(async (token) => {
let user = await getUserByToken(token);
return user !== null;
}, "INVALID_TOKEN"),
})
.nullable();
let sessionTypedCookie = createTypedCookie({ cookie, schema });
sessionTypedCookie.parse(request.headers.get("Cookie"));
Finally, to be able to delete a cookie, you can add .nullable()
to your schema and serialize it with null
as value.
await typedCookie.serialize(null, { expires: new Date(Date.now() - 1) });
If you didn't add .nullable()
to your schema, you will need to provide a mock value and set the expires date to the past.
let cookie = createCookie("returnTo", cookieOptions);
let schema = z.string().url().nullable();
let typedCookie = createTypedCookie({ cookie, schema });
await typedCookie.serialize("some fake url to pass schema validation", {
expires: new Date(Date.now() - 1),
});
Typed Sessions
Session objects in Remix allows any type, the typed sessions from Remix Utils lets you use Zod to parse the session data and ensure they conform to a schema.
import { createCookieSessionStorage } from "@remix-run/node";
import { createTypedSessionStorage } from "remix-utils";
import { z } from "zod";
let schema = z.object({
token: z.string().optional(),
count: z.number().default(1),
});
let sessionStorage = createCookieSessionStorage({ cookie });
let typedSessionStorage = createTypedSessionStorage({ sessionStorage, schema });
Now you can use typedSessionStorage as a drop-in replacement for your normal sessionStorage.
let session = typedSessionStorage.getSession(request.headers.get("Cookie"));
session.get("token");
session.get("count");
session.get("random");
session.has("token");
session.has("count");
session.set("token", 123);
Now Zod will ensure the data you try to save to the session is valid by not allowing you to get, set or unset data.
Note
Remember that you either need to mark fields as optional or set a default value in the schema, otherwise it will be impossible to call getSession to get a new session object.
You can also use async refinements in your schemas because typed sesions uses parseAsync method from Zod.
let schema = z.object({
token: z
.string()
.optional()
.refine(async (token) => {
if (!token) return true;
let user = await getUserByToken(token);
return user !== null;
}, "INVALID_TOKEN"),
});
let typedSessionStorage = createTypedSessionStorage({ sessionStorage, schema });
typedSessionStorage.getSession(request.headers.get("Cookie"));
Server-Sent Events
Server-Sent Events are a way to send data from the server to the client without the need for the client to request it. This is useful for things like chat applications, live updates, and more.
There are two utils provided to help with the usage inside Remix:
eventStream
useEventSource
The eventStream
function is used to create a new event stream response needed to send events to the client. This must live in a Resource Route.
import { eventStream } from "remix-utils";
export async function loader({ request }: LoaderArgs) {
return eventStream(request.signal, function setup(send) {
let timer = setInterval(() => {
send({ event: "time", data: new Date().toISOString() });
}, 1000);
return function clear() {
clearInterval(timer);
};
});
}
Then, inside any component, you can use the useEventSource
hook to connect to the event stream.
import { useEventSource } from "remix-utils";
function Counter() {
let time = useEventSource("/sse/time", { event: "time" });
if (!time) return null;
return (
<time dateTime={time}>
{new Date(time).toLocaleTimeString("en", {
minute: "2-digit",
second: "2-digit",
hour: "2-digit",
})}
</time>
);
}
The event
name in both the event stream and the hook is optional, in which case it will default to message
, if defined you must use the same event name in both sides, this also allows you to emit different events from the same event stream.
For Server-Sent Events to work, your server must support HTTP streaming. If you don't get SSE to work check if your deployment platform has support for it.
Because SSE count towards the limit of HTTP connections per domain, the useEventSource
hook keeps a global map of connections based on the provided URL and options. As long as they are the same, the hook will open a single SSE connection and share it between instances of the hook.
Once there are no more instances of the hook re-using a connection, it will be closed and removed from the map.
You can use the <EventSourceProvider />
component to control the map.
let map: EventSourceMap = new Map();
return (
<EventSourceProvider value={map}>
<YourAppOrPartOfIt />
</EventSourceProvider>
);
This way, you can overwrite the map with a new one for a specific part of your app. Note that this provider is optional and a default map will be used if you don't provide one.
Rolling Cookies
Rolling cookies allows you to prolong the expiration of a cookie by updating the expiration date of every cookie.
The rollingCookie
function is prepared to be used in entry.server
exported function to update the expiration date of a cookie if no loader set it.
For document request you can use it on the handleRequest
function:
import { rollingCookie } from "remix-utils";
import { sessionCookie } from "~/session.server";
export default function handleRequest(
request: Request,
responseStatusCode: number,
responseHeaders: Headers,
remixContext: EntryContext
) {
await rollingCookie(sessionCookie, request, responseHeaders);
return isbot(request.headers.get("user-agent"))
? handleBotRequest(
request,
responseStatusCode,
responseHeaders,
remixContext
)
: handleBrowserRequest(
request,
responseStatusCode,
responseHeaders,
remixContext
);
}
And for data request you can do it on the handleDataRequest
function:
export let handleDataRequest: HandleDataRequestFunction = async (
response: Response,
{ request }
) => {
let cookieValue = await sessionCookie.parse(
responseHeaders.get("set-cookie")
);
if (!cookieValue) {
cookieValue = await sessionCookie.parse(request.headers.get("cookie"));
responseHeaders.append(
"Set-Cookie",
await sessionCookie.serialize(cookieValue)
);
}
return response;
};
Note: Read more about rolling cookies in Remix.
Named actions
It's common to need to handle more than one action in the same route, there are many options here like sending the form to a resource route or using an action reducer, the namedAction
function uses some conventions to implement the action reducer pattern.
import { namedAction } from "remix-utils";
export async function action({ request }: ActionArgs) {
return namedAction(request, {
async create() {
},
async update() {
},
async delete() {
},
});
}
export default function Component() {
return (
<>
<Form method="post" action="?/create">
...
</Form>
<Form method="post" action="?/update">
...
</Form>
<Form method="post" action="?/delete">
...
</Form>
</>
);
}
This function can follow many conventions
You can pass a FormData object to the namedAction
, then it will try to
- Find a field named
/something
and use it as the action name removing the /
- Find a field named
intent
and use the value as the action name - Find a field named
action
and use the value as the action name - Find a field named
_action
and use the value as the action name
You can pass an URLSearchParams object to the namedAction
, then it will try to
- Find a query parameter named
/something
and use it as the action name removing the /
- Find a query parameter named
intent
and use the value as the action name - Find a query parameter named
action
and use the value as the action name - Find a query parameter named
_action
and use the value as the action name
You can pass an URL object to the namedAction
, it will behave as with a URLSearchParams object.
You can pass a Request object to the namedAction
, then it will try to
- Call
new URL(request.url)
and use it as the URL object - Call
request.formData()
and use it as the FormData object
If, in any case, the action name is not found, the actionName
then the library will try to call an action named default
, similar to a switch
in JavaScript.
If the default
is not defined it will throw a ReferenceError with the message Action "${name}" not found
.
If the library couldn't found the name at all, it will throw a ReferenceError with the message Action name not found
Preload Route Assets
The Link
header allows responses to push to the browser assets that are needed for the document, this is useful to improve the performance of the application by sending those assets earlier.
Once Early Hints is supported this will also allows you to send the assets even before the document is ready, but for now you can benefit to send assets to preload before the browser parse the HTML.
You can do this with the functions preloadRouteAssets
, preloadLinkedAssets
and preloadModuleAssets
.
All functions follows the same signature:
export default function handleRequest(
request: Request,
statusCode: number,
headers: Headers,
context: EntryContext
) {
let markup = renderToString(
<RemixServer context={context} url={request.url} />
);
headers.set("Content-Type", "text/html");
preloadRouteAssets(context, headers);
return new Response("<!DOCTYPE html>" + markup, {
status: statusCode,
headers: headers,
});
}
The preloadRouteAssets
is a combination of both preloadLinkedAssets
and preloadModuleAssets
so you can use it to preload all assets for a route, if you use this one you don't need the other two
The preloadLinkedAssets
function will preload any link with rel: "preload"
added with the Remix's LinkFunction
, so you can configure assets to preload in your route and send them in the headers automatically. It will additionally preload any linked stylesheet file (with rel: "stylesheet"
) even if not preloaded so it will load faster.
The preloadModuleAssets
function will preload all the JS files Remix adds to the page when hydrating it, Remix already renders a <link rel="modulepreload">
for each now before the <script type="module">
used to start the application, this will use Link headers to preload those assets.
Safe Redirects
When performing a redirect, if the URL is user provided we can't trust it, if you do you're opening a vulnerability to phishing scam by allowing bad actors to redirect the user to malicious websites.
https://remix.utills/?redirectTo=https://malicious.app
To help you prevent this Remix Utils gives you a safeRedirect
function which can be used to check if the URL is "safe".
Note: In this context, safe means the URL starts with /
but not //
, this means the URL is a pathname inside the same app and not an external link.
export async function loader({ request }: LoaderArgs) {
let { searchParams } = new URL(request.url);
let redirectTo = searchParams.get("redirectTo");
return redirect(safeRedirect(redirectTo, "/home"));
}
The second argumento of safeRedirect
is the default redirect which by when not configured is /
, this lets you tell safeRedirect
where to redirect the user if the value is not safe.
JSON Hash Response
When returning a json
from a loader
function, you may need to get data from different DB queries or API requests, typically you would something like this
export async function loader({ params }: LoaderData) {
let postId = z.string().parse(params.postId);
let [post, comments] = await Promise.all([getPost(), getComments()]);
return json({ post, comments });
async function getPost() {
}
async function getComments() {
}
}
The jsonHash
function lets you define those functions directly in the json
, reducing the need to create extra functions and variables.
export async function loader({ params }: LoaderData) {
let postId = z.string().parse(params.postId);
return jsonHash({
async post() {
},
async comments() {
},
});
}
It also calls your functions using Promise.all
so you can be sure the data is retrieved in parallel.
Additionally, you can pass non-async functions, values and promises.
export async function loader({ params }: LoaderData) {
let postId = z.string().parse(params.postId);
return jsonHash({
postId,
comments: getComments(),
slug() {
return postId.split("-").at(1);
},
async post() {
},
});
async function getComments() {
}
}
The result of jsonHash
is a TypedResponse
and it's correctly typed so using it with typeof loader
works flawlessly.
export default function Component() {
let { postId, comments, slug, post } = useLoaderData<typeof loader>();
}
Delegate Anchors to Remix
When using Remix, you can use the <Link>
component to navigate between pages. However, if you have a <a href>
that links to a page in your app, it will cause a full page refresh. This can be what you want, but sometimes you want to use client-side navigation here instead.
The useDelegatedAnchors
hook lets you add client-side navigation to anchor tags in a portion of your app. This can be specially useful when working with dynamic content like HTML or Markdown from a CMS.
import { useDelegatedAnchors } from "remix-utils";
export async function loader() {
let content = await fetchContentFromCMS();
return json({ content });
}
export default function Component() {
let { content } = useLoaderData<typeof loader>();
let ref = useRef<HTMLDivElement>(null);
useDelegatedAnchors(ref);
return <article ref={ref} dangerouslySetInnerHTML={{ __html: content }} />;
}
Prefetch Anchors
If additionally you want to be able to prefetch your anchors you can use the PrefetchPageAnchors
components.
This components wraps your content with anchors inside, it detects any hovered anchor to prefetch it, and it delegates them to Remix.
import { PrefetchPageAnchors } from "remix-utils";
export async function loader() {
let content = await fetchContentFromCMS();
return json({ content });
}
export default function Component() {
let { content } = useLoaderData<typeof loader>();
return (
<PrefetchPageAnchors>
<article ref={ref} dangerouslySetInnerHTML={{ __html: content }} />
</PrefetchPageAnchors>
);
}
Now you can see in your DevTools that when the user hovers an anchor it will prefetch it, and when the user clicks it will do a client-side navigation.
Debounced Fetcher
The useDebounceFetcher
is a wrapper of useFetcher
that adds debounce support to fetcher.submit
.
The hook is based on @JacobParis article.
The main difference with Jacob's version is that Remix Utils' version overwrites fetcher.submit
instead of appending a fetcher.debounceSubmit
method.
import { useDebounceFetcher } from "remix-utils";
export function Component({ data }) {
let fetcher = useDebounceFetcher<Type>();
function handleClick() {
fetcher.submit(data, { debounceTimeout: 1000 });
}
return (
<button type="button" onClick={handleClick}>
Do Something
</button>
);
}
Derive Fetcher init
Derive the value of the deprecated fetcher.type
from the fetcher and navigation data.
import { getFetcherType } from "remix-utils";
function Component() {
let fetcher = useFetcher();
let navigation = useNavigation();
let fetcherType = getFetcherType(fetcher, navigation);
useEffect(() => {
if (fetcherType === "done") {
}
}, [fetcherType]);
}
You can also use the React Hook API which let's you avoid calling useNavigation
.
import { useFetcherType } from "remix-utils";
function Component() {
let fetcher = useFetcher();
let fetcherType = useFetcherType(fetcher);
useEffect(() => {
if (fetcherType === "done") {
}
}, [fetcherType]);
}
If you need to pass the fetcher type around, you can also import FetcherType
type.
import { type FetcherType } from "remix-utils";
function useCallbackOnDone(type: FetcherType, cb) {
useEffect(() => {
if (type === "done") cb();
}, [type, cb]);
}
respondTo for Content Negotiation
If you're building a resource route and wants to send a different response based on what content type the client requested (e.g. send the same data as PDF or XML or JSON), you will need to implement content negotiation, this can be done with the respondTo
header.
import { respondTo } from "remix-utils";
export async function loader({ request }: LoaderArgs) {
let data = await getData(request);
let headers = new Headers({ vary: "accept" });
return respondTo(request, {
html() {
let body = ReactDOMServer.renderToString(<UI {...data} />);
headers.append("content-type", "text/html");
return new Response(body, { headers });
},
async "application/rss+xml"() {
let body = await generateRSSFeed(data);
headers.append("content-type", "application/rss+xml");
return new Response(body, { headers });
},
async text() {
let body = generatePlain(data);
headers.append("content-type", "text/plain");
return new Response(body, { headers });
},
default() {
return new Response("Not Acceptable", { status: 406 });
},
});
}
Now, the respondTo
function will check the Accept
header and call the correct handler, to know which one to call it will use the parseAcceptHeader
function also exported from Remix Utils
import { parseAcceptHeader } from "remix-utils";
let parsed = parseAcceptHeader(
"text/html, application/xhtml+xml, application/xml;q=0.9, image/webp, image/*, */*;q=0.8"
);
The result is an array with the type, subtype and extra params (e.g. the q
value). The order will be the same encountered in the header, in the example aabove text/html
will be the first, followed by application/xhtml+xml
.
This means that the respondTo
helper will prioritize any handler that match text/html
, in our example above, that will be the html
handler, but if we remove it then the text
handler will be called instead.67
Author
License