Route Module API
| app/routes/ |
| ├── _index.tsx |
| ├── about.tsx |
| ├── pricing.tsx |
| ├── blog._index.tsx |
| ├── blog.$slug.tsx |
| ├── blog.archive.tsx |
| └── blog.authors.tsx |
blog.$slug.tsx
| import { useParams } from "@remix-run/react"; |
| |
| export default function BlogPostPage() { |
| const { slug } = useParams<'slug'>(); |
| return ( |
| <p> |
| This is the blog post with slug {slug}. |
| </p> |
| ); |
| } |
| import { useParams } from "@remix-run/react"; |
| |
| export default function BlogPostPage() { |
| const { slug } = useParams<'slug'>(); |
| return ( |
| <p> |
| This is the blog post with slug {slug}. |
| </p> |
| ); |
| } |
| import { useParams } from "@remix-run/react"; |
| |
| export default function BlogPostPage() { |
| const { slug } = useParams<'slug'>(); |
| return ( |
| <p> |
| This is the blog post with slug {slug}. |
| </p> |
| ); |
| } |
| import { useParams } from "@remix-run/react"; |
| |
| export default function BlogPostPage() { |
| const { slug } = useParams<'slug'>(); |
| return ( |
| <p> |
| This is the blog post with slug {slug}. |
| </p> |
| ); |
| } |
How can we redirect to a different route?
E.g. / →
/blog
How can we redirect to a different route?
E.g. / →
/blog
_index.tsx
| import { redirect } from "@remix-run/node"; |
| |
| export function loader() { |
| return redirect("blog"); |
| } |
| import { redirect } from "@remix-run/node"; |
| |
| export function loader() { |
| return redirect("blog"); |
| } |
| import { redirect } from "@remix-run/node"; |
| |
| export function loader() { |
| return redirect("blog"); |
| } |
Must be a named export called loader.
_index.tsx
| import { redirect } from "@remix-run/node"; |
| |
| export function loader() { |
| return redirect("blog"); |
| } |
→ redirects to blog.tsx
Other Useful Route Module Exports
| export const links: LinksFunction = () => { |
| return [ |
| { rel: "icon", href: "/favicon.png", type: "image/png" }, |
| { rel: "stylesheet", href: "https://example.com/some/styles.css" }, |
| … |
| ]; |
| }; |
| |
| export const meta: V2_MetaFunction = () => { |
| return [ |
| { title: "Very cool app | Remix" }, |
| { property: "og:title", content: "Very cool app" }, |
| … |
| ]; |
| }; |
| |
| export const headers: HeadersFunction = () => ({ |
| "Cache-Control": "max-age=300, s-maxage=3600", |
| }); |
| |
| export default function BlogPostPage() { |
| … |
| } |
| export const links: LinksFunction = () => { |
| return [ |
| { rel: "icon", href: "/favicon.png", type: "image/png" }, |
| { rel: "stylesheet", href: "https://example.com/some/styles.css" }, |
| … |
| ]; |
| }; |
| |
| export const meta: V2_MetaFunction = () => { |
| return [ |
| { title: "Very cool app | Remix" }, |
| { property: "og:title", content: "Very cool app" }, |
| … |
| ]; |
| }; |
| |
| export const headers: HeadersFunction = () => ({ |
| "Cache-Control": "max-age=300, s-maxage=3600", |
| }); |
| |
| export default function BlogPostPage() { |
| … |
| } |
| export const links: LinksFunction = () => { |
| return [ |
| { rel: "icon", href: "/favicon.png", type: "image/png" }, |
| { rel: "stylesheet", href: "https://example.com/some/styles.css" }, |
| … |
| ]; |
| }; |
| |
| export const meta: V2_MetaFunction = () => { |
| return [ |
| { title: "Very cool app | Remix" }, |
| { property: "og:title", content: "Very cool app" }, |
| … |
| ]; |
| }; |
| |
| export const headers: HeadersFunction = () => ({ |
| "Cache-Control": "max-age=300, s-maxage=3600", |
| }); |
| |
| export default function BlogPostPage() { |
| … |
| } |
| export const links: LinksFunction = () => { |
| return [ |
| { rel: "icon", href: "/favicon.png", type: "image/png" }, |
| { rel: "stylesheet", href: "https://example.com/some/styles.css" }, |
| … |
| ]; |
| }; |
| |
| export const meta: V2_MetaFunction = () => { |
| return [ |
| { title: "Very cool app | Remix" }, |
| { property: "og:title", content: "Very cool app" }, |
| … |
| ]; |
| }; |
| |
| export const headers: HeadersFunction = () => ({ |
| "Cache-Control": "max-age=300, s-maxage=3600", |
| }); |
| |
| export default function BlogPostPage() { |
| … |
| } |
| export const links: LinksFunction = () => { |
| return [ |
| { rel: "icon", href: "/favicon.png", type: "image/png" }, |
| { rel: "stylesheet", href: "https://example.com/some/styles.css" }, |
| … |
| ]; |
| }; |
| |
| export const meta: V2_MetaFunction = () => { |
| return [ |
| { title: "Very cool app | Remix" }, |
| { property: "og:title", content: "Very cool app" }, |
| … |
| ]; |
| }; |
| |
| export const headers: HeadersFunction = () => ({ |
| "Cache-Control": "max-age=300, s-maxage=3600", |
| }); |
| |
| export default function BlogPostPage() { |
| … |
| } |
Loaders
-
Single, optional loader per route (export
loader)
- Run on the server (only)
-
Define a HTTP endpoint: request → response
-
useLoaderData to access response data
in component
blog.$slug.tsx
| import type { LoaderFunctionArgs } from "@remix-run/node"; |
| import { json, useLoaderData } from "@remix-run/node"; |
| |
| export async function loader({ request, params, context }: LoaderFunctionArgs) { |
| if (!params.slug) throw new Error("slug is required"); |
| |
| const post = await getBlogPost(params.slug); |
| |
| return json(post); |
| } |
| |
| export default function BlogPostPage() { |
| const post = useLoaderData<typeof loader>(); |
| |
| return <pre>{JSON.stringify(post, null, 2)}</pre>; |
| } |
| import type { LoaderFunctionArgs } from "@remix-run/node"; |
| import { json, useLoaderData } from "@remix-run/node"; |
| |
| export async function loader({ request, params, context }: LoaderFunctionArgs) { |
| if (!params.slug) throw new Error("slug is required"); |
| |
| const post = await getBlogPost(params.slug); |
| |
| return json(post); |
| } |
| |
| export default function BlogPostPage() { |
| const post = useLoaderData<typeof loader>(); |
| |
| return <pre>{JSON.stringify(post, null, 2)}</pre>; |
| } |
| import type { LoaderFunctionArgs } from "@remix-run/node"; |
| import { json, useLoaderData } from "@remix-run/node"; |
| |
| export async function loader({ request, params, context }: LoaderFunctionArgs) { |
| if (!params.slug) throw new Error("slug is required"); |
| |
| const post = await getBlogPost(params.slug); |
| |
| return json(post); |
| } |
| |
| export default function BlogPostPage() { |
| const post = useLoaderData<typeof loader>(); |
| |
| return <pre>{JSON.stringify(post, null, 2)}</pre>; |
| } |
Modifying the HTTP response
Modifying the HTTP response
Return a Response object…
| export async function loader({ request, params, context }: LoaderFunctionArgs) { |
| … |
| |
| if (notFound) { |
| return new Response(null, { status: 404, statusText: "Not found" }); |
| } |
| |
| return json(data); |
| } |
Modifying the HTTP response
…or throw it!
| export async function loader({ request, params, context }: LoaderFunctionArgs) { |
| … |
| |
| const post = await getBlogPost(params.slug); |
| |
| return json(data); |
| } |
| |
| export async function getBlogPost(slug: string) { |
| … |
| |
| if (notFound) { |
| throw new Response(null, { status: 404, statusText: "Not found" }); |
| } |
| |
| return post; |
| } |
| export async function loader({ request, params, context }: LoaderFunctionArgs) { |
| … |
| |
| const post = await getBlogPost(params.slug); |
| |
| return json(data); |
| } |
| |
| export async function getBlogPost(slug: string) { |
| … |
| |
| if (notFound) { |
| throw new Response(null, { status: 404, statusText: "Not found" }); |
| } |
| |
| return post; |
| } |
| export async function loader({ request, params, context }: LoaderFunctionArgs) { |
| … |
| |
| const post = await getBlogPost(params.slug); |
| |
| return json(data); |
| } |
| |
| export async function getBlogPost(slug: string) { |
| … |
| |
| if (notFound) { |
| throw new Response(null, { status: 404, statusText: "Not found" }); |
| } |
| |
| return post; |
| } |
| app/routes/ |
| ├── _index.tsx |
| ├── pokemon._index.tsx |
| ├── pokemon.$pokemonName.tsx |
| └── profile.tsx |
app/routes/_index.tsx
| import { redirect } from "@remix-run/node"; |
| |
| export function loader() { |
| return redirect("pokemon"); |
| } |
app/routes/pokemon._index.tsx
| export async function loader() { |
| const pokemonList = await fetcher<PokemonResultDto>( |
| "https://pokeapi.co/api/v2/pokemon?limit=1000", |
| ); |
| |
| return json(pokemonList.results.map((p) => p.name)); |
| } |
| |
| export default function ListPage() { |
| const pokemons = useLoaderData<typeof loader>(); |
| return <PokeList pokemons={pokemons} />; |
| } |
| export async function loader() { |
| const pokemonList = await fetcher<PokemonResultDto>( |
| "https://pokeapi.co/api/v2/pokemon?limit=1000", |
| ); |
| |
| return json(pokemonList.results.map((p) => p.name)); |
| } |
| |
| export default function ListPage() { |
| const pokemons = useLoaderData<typeof loader>(); |
| return <PokeList pokemons={pokemons} />; |
| } |
| export async function loader() { |
| const pokemonList = await fetcher<PokemonResultDto>( |
| "https://pokeapi.co/api/v2/pokemon?limit=1000", |
| ); |
| |
| return json(pokemonList.results.map((p) => p.name)); |
| } |
| |
| export default function ListPage() { |
| const pokemons = useLoaderData<typeof loader>(); |
| return <PokeList pokemons={pokemons} />; |
| } |
| export async function loader() { |
| const pokemonList = await fetcher<PokemonResultDto>( |
| "https://pokeapi.co/api/v2/pokemon?limit=1000", |
| ); |
| |
| return json(pokemonList.results.map((p) => p.name)); |
| } |
| |
| export default function ListPage() { |
| const pokemons = useLoaderData<typeof loader>(); |
| return <PokeList pokemons={pokemons} />; |
| } |
| export async function loader() { |
| const pokemonList = await fetcher<PokemonResultDto>( |
| "https://pokeapi.co/api/v2/pokemon?limit=1000", |
| ); |
| |
| return json(pokemonList.results.map((p) => p.name)); |
| } |
| |
| export default function ListPage() { |
| const pokemons = useLoaderData<typeof loader>(); |
| return <PokeList pokemons={pokemons} />; |
| } |
app/routes/pokemon.$pokemonName.tsx
| export async function loader({ params }: LoaderFunctionArgs) { |
| const pokemonDetail = await fetcher<PokemonDetailDto>( |
| `https://pokeapi.co/api/v2/pokemon/${params.pokemonName}`, |
| ); |
| |
| return json({ |
| name: pokemonDetail.name, |
| imageUrl: pokemonDetail.sprites.front_shiny, |
| }); |
| } |
| |
| export default function DetailPage() { |
| const pokemonDetail = useLoaderData<typeof loader>(); |
| return ( |
| <div> |
| <span>{pokemonDetail.name}</span> |
| <img src={pokemonDetail.imageUrl} alt={pokemonDetail.name} /> |
| </div> |
| ); |
| } |
| export async function loader({ params }: LoaderFunctionArgs) { |
| const pokemonDetail = await fetcher<PokemonDetailDto>( |
| `https://pokeapi.co/api/v2/pokemon/${params.pokemonName}`, |
| ); |
| |
| return json({ |
| name: pokemonDetail.name, |
| imageUrl: pokemonDetail.sprites.front_shiny, |
| }); |
| } |
| |
| export default function DetailPage() { |
| const pokemonDetail = useLoaderData<typeof loader>(); |
| return ( |
| <div> |
| <span>{pokemonDetail.name}</span> |
| <img src={pokemonDetail.imageUrl} alt={pokemonDetail.name} /> |
| </div> |
| ); |
| } |
| export async function loader({ params }: LoaderFunctionArgs) { |
| const pokemonDetail = await fetcher<PokemonDetailDto>( |
| `https://pokeapi.co/api/v2/pokemon/${params.pokemonName}`, |
| ); |
| |
| return json({ |
| name: pokemonDetail.name, |
| imageUrl: pokemonDetail.sprites.front_shiny, |
| }); |
| } |
| |
| export default function DetailPage() { |
| const pokemonDetail = useLoaderData<typeof loader>(); |
| return ( |
| <div> |
| <span>{pokemonDetail.name}</span> |
| <img src={pokemonDetail.imageUrl} alt={pokemonDetail.name} /> |
| </div> |
| ); |
| } |
| export async function loader({ params }: LoaderFunctionArgs) { |
| const pokemonDetail = await fetcher<PokemonDetailDto>( |
| `https://pokeapi.co/api/v2/pokemon/${params.pokemonName}`, |
| ); |
| |
| return json({ |
| name: pokemonDetail.name, |
| imageUrl: pokemonDetail.sprites.front_shiny, |
| }); |
| } |
| |
| export default function DetailPage() { |
| const pokemonDetail = useLoaderData<typeof loader>(); |
| return ( |
| <div> |
| <span>{pokemonDetail.name}</span> |
| <img src={pokemonDetail.imageUrl} alt={pokemonDetail.name} /> |
| </div> |
| ); |
| } |
| export async function loader({ params }: LoaderFunctionArgs) { |
| const pokemonDetail = await fetcher<PokemonDetailDto>( |
| `https://pokeapi.co/api/v2/pokemon/${params.pokemonName}`, |
| ); |
| |
| return json({ |
| name: pokemonDetail.name, |
| imageUrl: pokemonDetail.sprites.front_shiny, |
| }); |
| } |
| |
| export default function DetailPage() { |
| const pokemonDetail = useLoaderData<typeof loader>(); |
| return ( |
| <div> |
| <span>{pokemonDetail.name}</span> |
| <img src={pokemonDetail.imageUrl} alt={pokemonDetail.name} /> |
| </div> |
| ); |
| } |
app/api/fetcher.ts
| export async function fetcher<T>(uri: string, init?: RequestInit): Promise<T> { |
| const response = await fetch(uri, init); |
| |
| if (response.status === 404) { |
| throw new Response(null, { status: 404, statusText: "Not found" }); |
| } |
| |
| if (!response.ok) throw new Error("Could not fetch data!"); |
| return response.json(); |
| } |
| export async function fetcher<T>(uri: string, init?: RequestInit): Promise<T> { |
| const response = await fetch(uri, init); |
| |
| if (response.status === 404) { |
| throw new Response(null, { status: 404, statusText: "Not found" }); |
| } |
| |
| if (!response.ok) throw new Error("Could not fetch data!"); |
| return response.json(); |
| } |
app/components/layout/Layout.tsx
| import { Link, Outlet } from "@remix-run/react"; |
| import styles from "./Layout.module.css"; |
| |
| export function Layout() { |
| return ( |
| <div className={styles.root}> |
| <header> |
| <nav> |
| <Link to="/pokemon">Home</Link> | <Link to="/profile">Profile</Link> |
| </nav> |
| </header> |
| <main> |
| <Outlet /> |
| </main> |
| </div> |
| ); |
| } |
app/root.tsx
| … |
| |
| export default function App() { |
| return ( |
| <html lang="en"> |
| <head> |
| <meta charSet="utf-8" /> |
| <meta name="viewport" content="width=device-width, initial-scale=1" /> |
| <Meta /> |
| <Links /> |
| </head> |
| <body> |
| <Layout /> |
| <ScrollRestoration /> |
| <Scripts /> |
| <LiveReload /> |
| </body> |
| </html> |
| ); |
| } |
blog.$slug.tsx
| export default function BlogPostPage() { |
| return ( |
| <> |
| … |
| <form method="post"> |
| <input type="text" name="name" /> |
| <textarea name="comment" /> |
| <button type="submit">Submit</button> |
| </form> |
| </> |
| ); |
| } |
| export default function BlogPostPage() { |
| return ( |
| <> |
| … |
| <form method="post"> |
| <input type="text" name="name" /> |
| <textarea name="comment" /> |
| <button type="submit">Submit</button> |
| </form> |
| </> |
| ); |
| } |
| export default function BlogPostPage() { |
| return ( |
| <> |
| … |
| <form method="post"> |
| <input type="text" name="name" /> |
| <textarea name="comment" /> |
| <button type="submit">Submit</button> |
| </form> |
| </> |
| ); |
| } |
Let's start simple: We can use a native HTML form.
blog.$slug.tsx
| export async function action({ request }: ActionFunctionArgs) { |
| … |
| } |
| |
| export default function BlogPostPage() { |
| return ( |
| <> |
| … |
| <form method="post"> |
| … |
| <button type="submit">Submit</button> |
| </form> |
| </> |
| ); |
| } |
On the server the form is processed by an
action.
blog.$slug.tsx
| export async function action({ request }: ActionFunctionArgs) { |
| const form = await request.formData(); |
| const name = form.get("name"); |
| const comment = form.get("comment"); |
| |
| console.log(`name: ${name}`); |
| console.log(`comment: ${comment}`); |
| |
| return redirect("."); |
| } |
| |
| … |
Form data is read from the request using the standard
Request.formData()
method.
blog.$slug.tsx
| export async function action({ request }: ActionFunctionArgs) { |
| const form = await request.formData(); |
| const name = form.get("name"); |
| const comment = form.get("comment"); |
| |
| console.log(`name: ${name}`); |
| console.log(`comment: ${comment}`); |
| |
| return redirect("."); |
| } |
| |
| … |
The data can then be processed as desired (validated, stored,
etc.).
blog.$slug.tsx
| export async function action({ request }: ActionFunctionArgs) { |
| const form = await request.formData(); |
| const name = form.get("name"); |
| const comment = form.get("comment"); |
| |
| console.log(`name: ${name}`); |
| console.log(`comment: ${comment}`); |
| |
| return redirect("."); |
| } |
| |
| … |
Like the loader, the
action function can return a Response
object.
Typically, a redirect is returned to
implement the
Post/Redirect/Get
pattern.
blog.$slug.tsx
| … |
| |
| export default function BlogPostPage() { |
| return ( |
| <> |
| … |
| <form method="post"> |
| <input type="text" name="name" /> |
| <textarea name="comment" /> |
| <button type="submit">Submit</button> |
| </form> |
| </> |
| ); |
| } |
What will happen when the user clicks the
Submit button?
→ It works! But it does a full document request:
POST /blog/…
blog.$slug.tsx
| import { Form } from "@remix-run/react"; |
| |
| export default function BlogPostPage() { |
| return ( |
| <> |
| … |
| <Form method="post"> |
| <input type="text" name="name" /> |
| <textarea name="comment" /> |
| <button type="submit">Submit</button> |
| </Form> |
| </> |
| ); |
| } |
The Remix Form component brings the
expected SPA behavior.
It still has the native form behavior as
long as JS has not loaded yet.
→ Progressive Enhancement
Remix has built-in support for
sessions:
- Cookies
- In-memory (dev only)
- Files
- Cloudflare Workers
- Amazon DynamoDB
Let's work with the most versatile one:
app/sessions.ts
We set up our session handling utilities.
app/sessions.ts
| import { createCookieSessionStorage } from "@remix-run/node"; |
| |
| type SessionData = { … }; |
| |
| const { getSession, commitSession, destroySession } = |
| createCookieSessionStorage<SessionData>({ |
| cookie: { |
| name: "__session", |
| domain: "localhost", |
| httpOnly: true, |
| maxAge: 60 * 60 * 24, |
| path: "/", |
| sameSite: "lax", |
| secrets: ["s3cret1"], |
| secure: true, |
| }, |
| }); |
| |
| export { getSession, commitSession, destroySession }; |
| import { createCookieSessionStorage } from "@remix-run/node"; |
| |
| type SessionData = { … }; |
| |
| const { getSession, commitSession, destroySession } = |
| createCookieSessionStorage<SessionData>({ |
| cookie: { |
| name: "__session", |
| domain: "localhost", |
| httpOnly: true, |
| maxAge: 60 * 60 * 24, |
| path: "/", |
| sameSite: "lax", |
| secrets: ["s3cret1"], |
| secure: true, |
| }, |
| }); |
| |
| export { getSession, commitSession, destroySession }; |
| import { createCookieSessionStorage } from "@remix-run/node"; |
| |
| type SessionData = { … }; |
| |
| const { getSession, commitSession, destroySession } = |
| createCookieSessionStorage<SessionData>({ |
| cookie: { |
| name: "__session", |
| domain: "localhost", |
| httpOnly: true, |
| maxAge: 60 * 60 * 24, |
| path: "/", |
| sameSite: "lax", |
| secrets: ["s3cret1"], |
| secure: true, |
| }, |
| }); |
| |
| export { getSession, commitSession, destroySession }; |
| import { createCookieSessionStorage } from "@remix-run/node"; |
| |
| type SessionData = { … }; |
| |
| const { getSession, commitSession, destroySession } = |
| createCookieSessionStorage<SessionData>({ |
| cookie: { |
| name: "__session", |
| domain: "localhost", |
| httpOnly: true, |
| maxAge: 60 * 60 * 24, |
| path: "/", |
| sameSite: "lax", |
| secrets: ["s3cret1"], |
| secure: true, |
| }, |
| }); |
| |
| export { getSession, commitSession, destroySession }; |
| import { createCookieSessionStorage } from "@remix-run/node"; |
| |
| type SessionData = { … }; |
| |
| const { getSession, commitSession, destroySession } = |
| createCookieSessionStorage<SessionData>({ |
| cookie: { |
| name: "__session", |
| domain: "localhost", |
| httpOnly: true, |
| maxAge: 60 * 60 * 24, |
| path: "/", |
| sameSite: "lax", |
| secrets: ["s3cret1"], |
| secure: true, |
| }, |
| }); |
| |
| export { getSession, commitSession, destroySession }; |
We set up our session handling utilities.
And can use them in server-side code (typically
loaders and actions):
| |
| const session = await getSession(request.headers.get("Cookie")); |
| |
| |
| const color = session.get("color"); |
| |
| |
| session.set("color", "orange"); |
| |
| |
| return redirect(…, { |
| headers: { "Set-Cookie": await commitSession(session) }, |
| }); |
| |
| const session = await getSession(request.headers.get("Cookie")); |
| |
| |
| const color = session.get("color"); |
| |
| |
| session.set("color", "orange"); |
| |
| |
| return redirect(…, { |
| headers: { "Set-Cookie": await commitSession(session) }, |
| }); |
| |
| const session = await getSession(request.headers.get("Cookie")); |
| |
| |
| const color = session.get("color"); |
| |
| |
| session.set("color", "orange"); |
| |
| |
| return redirect(…, { |
| headers: { "Set-Cookie": await commitSession(session) }, |
| }); |
| |
| const session = await getSession(request.headers.get("Cookie")); |
| |
| |
| const color = session.get("color"); |
| |
| |
| session.set("color", "orange"); |
| |
| |
| return redirect(…, { |
| headers: { "Set-Cookie": await commitSession(session) }, |
| }); |
| |
| const session = await getSession(request.headers.get("Cookie")); |
| |
| |
| const color = session.get("color"); |
| |
| |
| session.set("color", "orange"); |
| |
| |
| return redirect(…, { |
| headers: { "Set-Cookie": await commitSession(session) }, |
| }); |
| |
| const session = await getSession(request.headers.get("Cookie")); |
| |
| |
| const color = session.get("color"); |
| |
| |
| session.set("color", "orange"); |
| |
| |
| return redirect(…, { |
| headers: { "Set-Cookie": await commitSession(session) }, |
| }); |
Validation (server-side)
What do we want to show in case of validation errors?
The form, with:
- The field values
-
Validation error messages per field
Thus, what do we need to do differently in our action?
| export async function action({ request }: ActionFunctionArgs) { |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| return redirect(…); |
| } |
| |
| … |
Thus, what do we need to do differently in our action?
| export async function action({ request }: ActionFunctionArgs) { |
| |
| |
| const requestIsValid = … |
| if (!requestIsValid) { |
| return json( |
| { values: { name }, errors: { name: "Name is invalid" } }, |
| { status: 400 } |
| ); |
| } |
| |
| |
| |
| return redirect(…); |
| } |
| |
| … |
| export async function action({ request }: ActionFunctionArgs) { |
| |
| |
| const requestIsValid = … |
| if (!requestIsValid) { |
| return json( |
| { values: { name }, errors: { name: "Name is invalid" } }, |
| { status: 400 } |
| ); |
| } |
| |
| |
| |
| return redirect(…); |
| } |
| |
| … |
| export async function action({ request }: ActionFunctionArgs) { |
| |
| |
| const requestIsValid = … |
| if (!requestIsValid) { |
| return json( |
| { values: { name }, errors: { name: "Name is invalid" } }, |
| { status: 400 } |
| ); |
| } |
| |
| |
| |
| return redirect(…); |
| } |
| |
| … |
| export async function action({ request }: ActionFunctionArgs) { |
| |
| |
| const requestIsValid = … |
| if (!requestIsValid) { |
| return json( |
| { values: { name }, errors: { name: "Name is invalid" } }, |
| { status: 400 } |
| ); |
| } |
| |
| |
| |
| return redirect(…); |
| } |
| |
| … |
If request is invalid, return a HTTP
400 response with the field values and
errors.
In the component, useActionData to
access the data returned by the action:
| … |
| |
| export default function MyPage() { |
| const actionData = useActionData<typeof action>(); |
| |
| return <> |
| … |
| <Form method="post"> |
| <input |
| type="text" |
| name="name" |
| defaultValue={actionData?.values.name} |
| /> |
| {actionData?.errors.name} |
| … |
| </Form> |
| </>; |
| } |
| … |
| |
| export default function MyPage() { |
| const actionData = useActionData<typeof action>(); |
| |
| return <> |
| … |
| <Form method="post"> |
| <input |
| type="text" |
| name="name" |
| defaultValue={actionData?.values.name} |
| /> |
| {actionData?.errors.name} |
| … |
| </Form> |
| </>; |
| } |
| … |
| |
| export default function MyPage() { |
| const actionData = useActionData<typeof action>(); |
| |
| return <> |
| … |
| <Form method="post"> |
| <input |
| type="text" |
| name="name" |
| defaultValue={actionData?.values.name} |
| /> |
| {actionData?.errors.name} |
| … |
| </Form> |
| </>; |
| } |
| … |
| |
| export default function MyPage() { |
| const actionData = useActionData<typeof action>(); |
| |
| return <> |
| … |
| <Form method="post"> |
| <input |
| type="text" |
| name="name" |
| defaultValue={actionData?.values.name} |
| /> |
| {actionData?.errors.name} |
| … |
| </Form> |
| </>; |
| } |
app/components/profile/Profile.tsx
| type ProfileProps = { |
| values?: ProfileValues; |
| errors?: ProfileErrors; |
| }; |
| |
| export type ProfileValues = { |
| name: string; |
| email: string; |
| }; |
| |
| export type ProfileErrors = { |
| [K in keyof ProfileValues]?: string; |
| }; |
| |
| export function Profile({ values, errors }: ProfileProps) { … } |
| type ProfileProps = { |
| values?: ProfileValues; |
| errors?: ProfileErrors; |
| }; |
| |
| export type ProfileValues = { |
| name: string; |
| email: string; |
| }; |
| |
| export type ProfileErrors = { |
| [K in keyof ProfileValues]?: string; |
| }; |
| |
| export function Profile({ values, errors }: ProfileProps) { … } |
| type ProfileProps = { |
| values?: ProfileValues; |
| errors?: ProfileErrors; |
| }; |
| |
| export type ProfileValues = { |
| name: string; |
| email: string; |
| }; |
| |
| export type ProfileErrors = { |
| [K in keyof ProfileValues]?: string; |
| }; |
| |
| export function Profile({ values, errors }: ProfileProps) { … } |
app/components/profile/Profile.tsx
| … |
| |
| export function Profile() { |
| return ( |
| <Form method="post"> |
| <input |
| … |
| name="name" |
| className={errors?.name ? styles.inputError : ""} |
| defaultValue={values?.name} |
| /> |
| {errors?.name && <span>{errors.name}</span>} |
| <input |
| … |
| name="email" |
| className={errors?.email ? styles.inputError : ""} |
| defaultValue={values?.email} |
| /> |
| {errors?.email && <span>{errors.email}</span>} |
| <input type="submit" value="save" /> |
| </Form> |
| ); |
| } |
| … |
| |
| export function Profile() { |
| return ( |
| <Form method="post"> |
| <input |
| … |
| name="name" |
| className={errors?.name ? styles.inputError : ""} |
| defaultValue={values?.name} |
| /> |
| {errors?.name && <span>{errors.name}</span>} |
| <input |
| … |
| name="email" |
| className={errors?.email ? styles.inputError : ""} |
| defaultValue={values?.email} |
| /> |
| {errors?.email && <span>{errors.email}</span>} |
| <input type="submit" value="save" /> |
| </Form> |
| ); |
| } |
| … |
| |
| export function Profile() { |
| return ( |
| <Form method="post"> |
| <input |
| … |
| name="name" |
| className={errors?.name ? styles.inputError : ""} |
| defaultValue={values?.name} |
| /> |
| {errors?.name && <span>{errors.name}</span>} |
| <input |
| … |
| name="email" |
| className={errors?.email ? styles.inputError : ""} |
| defaultValue={values?.email} |
| /> |
| {errors?.email && <span>{errors.email}</span>} |
| <input type="submit" value="save" /> |
| </Form> |
| ); |
| } |
| … |
| |
| export function Profile() { |
| return ( |
| <Form method="post"> |
| <input |
| … |
| name="name" |
| className={errors?.name ? styles.inputError : ""} |
| defaultValue={values?.name} |
| /> |
| {errors?.name && <span>{errors.name}</span>} |
| <input |
| … |
| name="email" |
| className={errors?.email ? styles.inputError : ""} |
| defaultValue={values?.email} |
| /> |
| {errors?.email && <span>{errors.email}</span>} |
| <input type="submit" value="save" /> |
| </Form> |
| ); |
| } |
| … |
| |
| export function Profile() { |
| return ( |
| <Form method="post"> |
| <input |
| … |
| name="name" |
| className={errors?.name ? styles.inputError : ""} |
| defaultValue={values?.name} |
| /> |
| {errors?.name && <span>{errors.name}</span>} |
| <input |
| … |
| name="email" |
| className={errors?.email ? styles.inputError : ""} |
| defaultValue={values?.email} |
| /> |
| {errors?.email && <span>{errors.email}</span>} |
| <input type="submit" value="save" /> |
| </Form> |
| ); |
| } |
| … |
| |
| export function Profile() { |
| return ( |
| <Form method="post"> |
| <input |
| … |
| name="name" |
| className={errors?.name ? styles.inputError : ""} |
| defaultValue={values?.name} |
| /> |
| {errors?.name && <span>{errors.name}</span>} |
| <input |
| … |
| name="email" |
| className={errors?.email ? styles.inputError : ""} |
| defaultValue={values?.email} |
| /> |
| {errors?.email && <span>{errors.email}</span>} |
| <input type="submit" value="save" /> |
| </Form> |
| ); |
| } |
app/sessions.ts
| import { createCookieSessionStorage } from "@remix-run/node"; |
| |
| type SessionData = { |
| name: string; |
| email: string; |
| }; |
| |
| const { getSession, commitSession, destroySession } = |
| createCookieSessionStorage<SessionData>({ |
| cookie: { |
| name: "__session", |
| domain: "localhost", |
| httpOnly: true, |
| maxAge: 60 * 60 * 24, |
| path: "/", |
| sameSite: "lax", |
| secrets: ["s3cret1"], |
| secure: true, |
| }, |
| }); |
| |
| export { getSession, commitSession, destroySession }; |
| import { createCookieSessionStorage } from "@remix-run/node"; |
| |
| type SessionData = { |
| name: string; |
| email: string; |
| }; |
| |
| const { getSession, commitSession, destroySession } = |
| createCookieSessionStorage<SessionData>({ |
| cookie: { |
| name: "__session", |
| domain: "localhost", |
| httpOnly: true, |
| maxAge: 60 * 60 * 24, |
| path: "/", |
| sameSite: "lax", |
| secrets: ["s3cret1"], |
| secure: true, |
| }, |
| }); |
| |
| export { getSession, commitSession, destroySession }; |
| import { createCookieSessionStorage } from "@remix-run/node"; |
| |
| type SessionData = { |
| name: string; |
| email: string; |
| }; |
| |
| const { getSession, commitSession, destroySession } = |
| createCookieSessionStorage<SessionData>({ |
| cookie: { |
| name: "__session", |
| domain: "localhost", |
| httpOnly: true, |
| maxAge: 60 * 60 * 24, |
| path: "/", |
| sameSite: "lax", |
| secrets: ["s3cret1"], |
| secure: true, |
| }, |
| }); |
| |
| export { getSession, commitSession, destroySession }; |
app/routes/profile.tsx
| export async function action({ request }: ActionFunctionArgs) { |
| const session = await getSession(request.headers.get("Cookie")); |
| |
| const form = await request.formData(); |
| const name = form.get("name"); |
| const email = form.get("email"); |
| |
| if (typeof name !== "string") throw new Error("name must be a string"); |
| if (typeof email !== "string") throw new Error("email must be a string"); |
| |
| const errors = validateProfile({ name, email }); |
| |
| if (hasErrors(errors)) { |
| return json({ values: { name, email }, errors }, { status: 400 }); |
| } |
| |
| session.set("name", name); |
| session.set("email", email); |
| |
| return redirect(".", { |
| headers: { "Set-Cookie": await commitSession(session) }, |
| }); |
| } |
| |
| … |
| export async function action({ request }: ActionFunctionArgs) { |
| const session = await getSession(request.headers.get("Cookie")); |
| |
| const form = await request.formData(); |
| const name = form.get("name"); |
| const email = form.get("email"); |
| |
| if (typeof name !== "string") throw new Error("name must be a string"); |
| if (typeof email !== "string") throw new Error("email must be a string"); |
| |
| const errors = validateProfile({ name, email }); |
| |
| if (hasErrors(errors)) { |
| return json({ values: { name, email }, errors }, { status: 400 }); |
| } |
| |
| session.set("name", name); |
| session.set("email", email); |
| |
| return redirect(".", { |
| headers: { "Set-Cookie": await commitSession(session) }, |
| }); |
| } |
| |
| … |
| export async function action({ request }: ActionFunctionArgs) { |
| const session = await getSession(request.headers.get("Cookie")); |
| |
| const form = await request.formData(); |
| const name = form.get("name"); |
| const email = form.get("email"); |
| |
| if (typeof name !== "string") throw new Error("name must be a string"); |
| if (typeof email !== "string") throw new Error("email must be a string"); |
| |
| const errors = validateProfile({ name, email }); |
| |
| if (hasErrors(errors)) { |
| return json({ values: { name, email }, errors }, { status: 400 }); |
| } |
| |
| session.set("name", name); |
| session.set("email", email); |
| |
| return redirect(".", { |
| headers: { "Set-Cookie": await commitSession(session) }, |
| }); |
| } |
| |
| … |
| export async function action({ request }: ActionFunctionArgs) { |
| const session = await getSession(request.headers.get("Cookie")); |
| |
| const form = await request.formData(); |
| const name = form.get("name"); |
| const email = form.get("email"); |
| |
| if (typeof name !== "string") throw new Error("name must be a string"); |
| if (typeof email !== "string") throw new Error("email must be a string"); |
| |
| const errors = validateProfile({ name, email }); |
| |
| if (hasErrors(errors)) { |
| return json({ values: { name, email }, errors }, { status: 400 }); |
| } |
| |
| session.set("name", name); |
| session.set("email", email); |
| |
| return redirect(".", { |
| headers: { "Set-Cookie": await commitSession(session) }, |
| }); |
| } |
| |
| … |
| export async function action({ request }: ActionFunctionArgs) { |
| const session = await getSession(request.headers.get("Cookie")); |
| |
| const form = await request.formData(); |
| const name = form.get("name"); |
| const email = form.get("email"); |
| |
| if (typeof name !== "string") throw new Error("name must be a string"); |
| if (typeof email !== "string") throw new Error("email must be a string"); |
| |
| const errors = validateProfile({ name, email }); |
| |
| if (hasErrors(errors)) { |
| return json({ values: { name, email }, errors }, { status: 400 }); |
| } |
| |
| session.set("name", name); |
| session.set("email", email); |
| |
| return redirect(".", { |
| headers: { "Set-Cookie": await commitSession(session) }, |
| }); |
| } |
| |
| … |
| export async function action({ request }: ActionFunctionArgs) { |
| const session = await getSession(request.headers.get("Cookie")); |
| |
| const form = await request.formData(); |
| const name = form.get("name"); |
| const email = form.get("email"); |
| |
| if (typeof name !== "string") throw new Error("name must be a string"); |
| if (typeof email !== "string") throw new Error("email must be a string"); |
| |
| const errors = validateProfile({ name, email }); |
| |
| if (hasErrors(errors)) { |
| return json({ values: { name, email }, errors }, { status: 400 }); |
| } |
| |
| session.set("name", name); |
| session.set("email", email); |
| |
| return redirect(".", { |
| headers: { "Set-Cookie": await commitSession(session) }, |
| }); |
| } |
| |
| … |
| export async function action({ request }: ActionFunctionArgs) { |
| const session = await getSession(request.headers.get("Cookie")); |
| |
| const form = await request.formData(); |
| const name = form.get("name"); |
| const email = form.get("email"); |
| |
| if (typeof name !== "string") throw new Error("name must be a string"); |
| if (typeof email !== "string") throw new Error("email must be a string"); |
| |
| const errors = validateProfile({ name, email }); |
| |
| if (hasErrors(errors)) { |
| return json({ values: { name, email }, errors }, { status: 400 }); |
| } |
| |
| session.set("name", name); |
| session.set("email", email); |
| |
| return redirect(".", { |
| headers: { "Set-Cookie": await commitSession(session) }, |
| }); |
| } |
| |
| … |
app/routes/profile.tsx
| export async function action({ request }: ActionFunctionArgs) { |
| … |
| |
| const errors = validateProfile({ name, email }); |
| |
| if (hasErrors(errors)) { |
| … |
| } |
| |
| … |
| } |
| |
| function validateProfile({ name, email }: ProfileValues) { … } |
| |
| function hasErrors(errors: ProfileErrors) { |
| return Object.values(errors).length > 0; |
| } |
| |
| export default function ProfilePage() { |
| const actionData = useActionData<typeof action>(); |
| return <Profile values={actionData?.values} errors={actionData?.errors} />; |
| } |
| export async function action({ request }: ActionFunctionArgs) { |
| … |
| |
| const errors = validateProfile({ name, email }); |
| |
| if (hasErrors(errors)) { |
| … |
| } |
| |
| … |
| } |
| |
| function validateProfile({ name, email }: ProfileValues) { … } |
| |
| function hasErrors(errors: ProfileErrors) { |
| return Object.values(errors).length > 0; |
| } |
| |
| export default function ProfilePage() { |
| const actionData = useActionData<typeof action>(); |
| return <Profile values={actionData?.values} errors={actionData?.errors} />; |
| } |
app/routes/profile.tsx
| … |
| |
| function validateProfile({ name, email }: ProfileValues) { |
| const errors: ProfileErrors = {}; |
| |
| if (!name || typeof name !== "string") { |
| errors.name = "Name is required!"; |
| } else if (name.length > 25) { |
| errors.name = "The name should be max. 25 characters long!"; |
| } |
| |
| if (!email || typeof email !== "string") { |
| errors.email = "Email is required!"; |
| } else if (!email.match(/^[A-Z0-9._+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i)) { |
| errors.email = "Invalid email!"; |
| } |
| |
| return errors; |
| } |
| |
| … |
| … |
| |
| function validateProfile({ name, email }: ProfileValues) { |
| const errors: ProfileErrors = {}; |
| |
| if (!name || typeof name !== "string") { |
| errors.name = "Name is required!"; |
| } else if (name.length > 25) { |
| errors.name = "The name should be max. 25 characters long!"; |
| } |
| |
| if (!email || typeof email !== "string") { |
| errors.email = "Email is required!"; |
| } else if (!email.match(/^[A-Z0-9._+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i)) { |
| errors.email = "Invalid email!"; |
| } |
| |
| return errors; |
| } |
| |
| … |
app/components/layout/Layout.tsx
| type LayoutProps = { |
| username?: string; |
| }; |
| |
| export function Layout({ username }: LayoutProps) { |
| return ( |
| <div className={styles.root}> |
| <header> |
| <nav> |
| <Link to="/pokemon">Home</Link> | <Link to="/profile">Profile</Link> |
| </nav> |
| <span>{username && `Hello, ${username}`}</span> |
| </header> |
| <main> |
| <Outlet /> |
| </main> |
| </div> |
| ); |
| } |
app/root.tsx
| export async function loader({ request }: LoaderFunctionArgs) { |
| const session = await getSession(request.headers.get("Cookie")); |
| |
| return json({ username: session.get("name") }); |
| } |
| |
| export default function App() { |
| const { username } = useLoaderData<typeof loader>(); |
| return ( |
| <html lang="en"> |
| <head> |
| <meta charSet="utf-8" /> |
| <meta name="viewport" content="width=device-width,initial-scale=1" /> |
| <Meta /> |
| <Links /> |
| </head> |
| <body> |
| <Layout username={username} /> |
| <ScrollRestoration /> |
| <Scripts /> |
| <LiveReload /> |
| </body> |
| </html> |
| ); |
| } |
| export async function loader({ request }: LoaderFunctionArgs) { |
| const session = await getSession(request.headers.get("Cookie")); |
| |
| return json({ username: session.get("name") }); |
| } |
| |
| export default function App() { |
| const { username } = useLoaderData<typeof loader>(); |
| return ( |
| <html lang="en"> |
| <head> |
| <meta charSet="utf-8" /> |
| <meta name="viewport" content="width=device-width,initial-scale=1" /> |
| <Meta /> |
| <Links /> |
| </head> |
| <body> |
| <Layout username={username} /> |
| <ScrollRestoration /> |
| <Scripts /> |
| <LiveReload /> |
| </body> |
| </html> |
| ); |
| } |
| export async function loader({ request }: LoaderFunctionArgs) { |
| const session = await getSession(request.headers.get("Cookie")); |
| |
| return json({ username: session.get("name") }); |
| } |
| |
| export default function App() { |
| const { username } = useLoaderData<typeof loader>(); |
| return ( |
| <html lang="en"> |
| <head> |
| <meta charSet="utf-8" /> |
| <meta name="viewport" content="width=device-width,initial-scale=1" /> |
| <Meta /> |
| <Links /> |
| </head> |
| <body> |
| <Layout username={username} /> |
| <ScrollRestoration /> |
| <Scripts /> |
| <LiveReload /> |
| </body> |
| </html> |
| ); |
| } |
| export async function loader({ request }: LoaderFunctionArgs) { |
| const session = await getSession(request.headers.get("Cookie")); |
| |
| return json({ username: session.get("name") }); |
| } |
| |
| export default function App() { |
| const { username } = useLoaderData<typeof loader>(); |
| return ( |
| <html lang="en"> |
| <head> |
| <meta charSet="utf-8" /> |
| <meta name="viewport" content="width=device-width,initial-scale=1" /> |
| <Meta /> |
| <Links /> |
| </head> |
| <body> |
| <Layout username={username} /> |
| <ScrollRestoration /> |
| <Scripts /> |
| <LiveReload /> |
| </body> |
| </html> |
| ); |
| } |
| export async function loader({ request }: LoaderFunctionArgs) { |
| const session = await getSession(request.headers.get("Cookie")); |
| |
| return json({ username: session.get("name") }); |
| } |
| |
| export default function App() { |
| const { username } = useLoaderData<typeof loader>(); |
| return ( |
| <html lang="en"> |
| <head> |
| <meta charSet="utf-8" /> |
| <meta name="viewport" content="width=device-width,initial-scale=1" /> |
| <Meta /> |
| <Links /> |
| </head> |
| <body> |
| <Layout username={username} /> |
| <ScrollRestoration /> |
| <Scripts /> |
| <LiveReload /> |
| </body> |
| </html> |
| ); |
| } |