Skip to content

Commit ebebe23

Browse files
committed
feat: add zod example
1 parent 87b89ea commit ebebe23

14 files changed

+640
-0
lines changed

zod/.eslintrc.js

+4
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
/** @type {import('eslint').Linter.Config} */
2+
module.exports = {
3+
extends: ["@remix-run/eslint-config", "@remix-run/eslint-config/node"],
4+
};

zod/.gitignore

+6
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
node_modules
2+
3+
/.cache
4+
/build
5+
/public/build
6+
.env

zod/README.md

+49
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
# Zod Example
2+
3+
This example demonstrates how to use [Zod](https://npm.im/zod) for server-side validation and data transformation in a Remix application. It includes a user registration form and a product listing page.
4+
5+
In the user registration form, Zod is used to validate and transform POST data which is submitted by the form in the action handler.
6+
7+
In the product listing page, Zod is used to validate and transform GET query parameters which are used for filtering and pagination in the loader.
8+
9+
Every validation and data transformation is done on the server-side, so the client can use the app without JavaScript enabled.
10+
11+
Enjoy Remix's progressively enhanced forms 💿 and Zod's type safety 💎!
12+
13+
## Preview
14+
15+
Open this example on [CodeSandbox](https://codesandbox.com):
16+
17+
[![Open in CodeSandbox](https://codesandbox.io/static/img/play-codesandbox.svg)](https://codesandbox.io/s/github/remix-run/examples/tree/main/zod)
18+
19+
## Example
20+
21+
### `app/root.tsx`
22+
23+
A simple error boundary component is added to catch the errors and display error messages.
24+
25+
### `app/routes/index.tsx`
26+
27+
This file contains the user registration form and its submission handling logic. It leverages Zod for validating and transforming the POST data.
28+
29+
### `app/routes/products.tsx`
30+
31+
This file implements the product listing page, including filters and pagination. It leverages Zod for URL query parameter validation and transforming. A cache control header is added to the response to ensure the page is cached also.
32+
33+
---
34+
35+
Following two files are used for mocking and functionality demonstration. They are not directly related to Zod or Remix.
36+
37+
#### `app/lib/product.server.ts`
38+
39+
This file defines the schema for the product data and provides a mock product list. It's used to ensure the type safety of the product data.
40+
41+
#### `app/lib/utils.server.ts`
42+
43+
This file contains `isDateFormat` utility which is used for date validation for `<input type="date" />` and a function for calculating days until the next birthday.
44+
45+
## Related Links
46+
47+
- [Remix Documentation](https://remix.run/docs)
48+
- [Zod Documentation](https://github.com/colinhacks/zod/#zod)
49+
- [Zod: Coercion for Primitives](https://github.com/colinhacks/zod/#coercion-for-primitives)

zod/app/lib/product.server.ts

+87
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import * as z from "zod";
2+
3+
const ProductSchema = z.object({
4+
id: z.number().int().positive(),
5+
name: z.string(),
6+
price: z.number(),
7+
});
8+
9+
type Product = z.infer<typeof ProductSchema>;
10+
11+
export const mockProducts: Product[] = [
12+
{
13+
id: 1,
14+
name: "Laptop",
15+
price: 900,
16+
},
17+
{
18+
id: 2,
19+
name: "Smartphone",
20+
price: 700,
21+
},
22+
{
23+
id: 3,
24+
name: "T-shirt",
25+
price: 20,
26+
},
27+
{
28+
id: 4,
29+
name: "Jeans",
30+
price: 50,
31+
},
32+
{
33+
id: 5,
34+
name: "Running Shoes",
35+
price: 90,
36+
},
37+
{
38+
id: 6,
39+
name: "Bluetooth Speaker",
40+
price: 50,
41+
},
42+
{
43+
id: 7,
44+
name: "Dress Shirt",
45+
price: 30,
46+
},
47+
{
48+
id: 8,
49+
name: "Gaming Console",
50+
price: 350,
51+
},
52+
{
53+
id: 9,
54+
name: "Sneakers",
55+
price: 120,
56+
},
57+
{
58+
id: 10,
59+
name: "Watch",
60+
price: 200,
61+
},
62+
{
63+
id: 11,
64+
name: "Hoodie",
65+
price: 40,
66+
},
67+
{
68+
id: 12,
69+
name: "Guitar",
70+
price: 300,
71+
},
72+
{
73+
id: 13,
74+
name: "Fitness Tracker",
75+
price: 80,
76+
},
77+
{
78+
id: 14,
79+
name: "Backpack",
80+
price: 50,
81+
},
82+
{
83+
id: 15,
84+
name: "Dumbbell Set",
85+
price: 130,
86+
},
87+
];

zod/app/lib/utils.server.ts

+16
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
export const isDateFormat = (date: string): boolean => {
2+
return /^\d{4}-\d{2}-\d{2}$/.test(date);
3+
};
4+
5+
export const calculateDaysUntilNextBirthday = (birthday: Date): number => {
6+
const today = new Date();
7+
const currentYear = today.getFullYear();
8+
const birthdayThisYear = new Date(
9+
currentYear,
10+
birthday.getMonth(),
11+
birthday.getDate(),
12+
);
13+
const diff = birthdayThisYear.getTime() - today.getTime();
14+
const days = Math.ceil(diff / (1000 * 3600 * 24));
15+
return days < 0 ? 365 + days : days;
16+
};

zod/app/root.tsx

+41
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import type { MetaFunction } from "@remix-run/node";
2+
import {
3+
Links,
4+
LiveReload,
5+
Meta,
6+
Outlet,
7+
Scripts,
8+
ScrollRestoration,
9+
} from "@remix-run/react";
10+
11+
export const meta: MetaFunction = () => ({
12+
charset: "utf-8",
13+
title: "Remix 💿 + Zod 💎",
14+
viewport: "width=device-width,initial-scale=1",
15+
});
16+
17+
export default function App() {
18+
return (
19+
<html lang="en">
20+
<head>
21+
<Meta />
22+
<Links />
23+
</head>
24+
<body>
25+
<Outlet />
26+
<ScrollRestoration />
27+
<Scripts />
28+
<LiveReload />
29+
</body>
30+
</html>
31+
);
32+
}
33+
34+
export const ErrorBoundary = ({ error }: { error: Error }) => {
35+
return (
36+
<div>
37+
<h1>App Error</h1>
38+
<pre>{error.message}</pre>
39+
</div>
40+
);
41+
};

zod/app/routes/index.tsx

+170
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
import { ActionArgs, json } from "@remix-run/node";
2+
import { Form, Link, useActionData, useNavigation } from "@remix-run/react";
3+
import * as z from "zod";
4+
5+
import {
6+
calculateDaysUntilNextBirthday,
7+
isDateFormat,
8+
} from "~/lib/utils.server";
9+
10+
export const action = async ({ request }: ActionArgs) => {
11+
const formData = await request.formData();
12+
const payload = Object.fromEntries(formData.entries());
13+
14+
const currentDate = new Date();
15+
16+
const schema = z.object({
17+
firstName: z
18+
.string()
19+
.min(2, "Must be at least 2 characters")
20+
.max(50, "Must be less than 50 characters"),
21+
email: z.string().email("Must be a valid email"),
22+
birthday: z.coerce
23+
.date()
24+
.min(
25+
new Date(
26+
currentDate.getFullYear() - 26,
27+
currentDate.getMonth(),
28+
currentDate.getDate(),
29+
),
30+
"Must be younger than 25",
31+
)
32+
.max(
33+
new Date(
34+
currentDate.getFullYear() - 18,
35+
currentDate.getMonth(),
36+
currentDate.getDate(),
37+
),
38+
"Must be at least 18 years old",
39+
),
40+
});
41+
42+
const parseResult = schema.safeParse(payload);
43+
44+
if (!parseResult.success) {
45+
const fields = {
46+
firstName: typeof payload.firstName === "string" ? payload.firstName : "",
47+
email: typeof payload.email === "string" ? payload.email : "",
48+
birthday:
49+
typeof payload.birthday === "string" && isDateFormat(payload.birthday)
50+
? payload.birthday
51+
: "",
52+
};
53+
54+
return json(
55+
{
56+
fieldErrors: parseResult.error.flatten().fieldErrors,
57+
fields,
58+
message: null,
59+
},
60+
{
61+
status: 400,
62+
},
63+
);
64+
}
65+
66+
return json({
67+
fieldErrors: null,
68+
fields: null,
69+
message: `Hello ${parseResult.data.firstName}! We will send an email to ${
70+
parseResult.data.email
71+
} for your discount code in ${calculateDaysUntilNextBirthday(
72+
parseResult.data.birthday,
73+
)} days.`,
74+
});
75+
};
76+
77+
const errorTextStyle: React.CSSProperties = {
78+
fontWeight: "bold",
79+
color: "red",
80+
marginInline: 0,
81+
marginBlock: "0.25rem",
82+
};
83+
84+
export default function RegisterView() {
85+
const actionData = useActionData<typeof action>();
86+
const navigation = useNavigation();
87+
const isSubmitting = navigation.state === "submitting";
88+
89+
if (actionData?.message) {
90+
return (
91+
<div>
92+
<h3>{actionData.message}</h3>
93+
<hr />
94+
<Link to="/products">View Products</Link>
95+
</div>
96+
);
97+
}
98+
99+
return (
100+
<div>
101+
<h1>Register for a birthday discount!</h1>
102+
<Form method="post">
103+
<div>
104+
<label htmlFor="firstName">First Name:</label>
105+
<input
106+
type="text"
107+
id="firstName"
108+
name="firstName"
109+
defaultValue={actionData?.fields?.firstName}
110+
/>
111+
{actionData?.fieldErrors?.firstName
112+
? actionData.fieldErrors.firstName.map((error, index) => (
113+
<p style={errorTextStyle} key={`first-name-error-${index}`}>
114+
{error}
115+
</p>
116+
))
117+
: null}
118+
</div>
119+
120+
<br />
121+
122+
<div>
123+
<label htmlFor="email">Email:</label>
124+
<input
125+
type="email"
126+
id="email"
127+
name="email"
128+
defaultValue={actionData?.fields?.email}
129+
/>
130+
{actionData?.fieldErrors?.email
131+
? actionData.fieldErrors.email.map((error, index) => (
132+
<p style={errorTextStyle} key={`email-error-${index}`}>
133+
{error}
134+
</p>
135+
))
136+
: null}
137+
</div>
138+
139+
<br />
140+
141+
<div>
142+
<label htmlFor="birthday">Birthday:</label>
143+
<input
144+
type="date"
145+
id="birthday"
146+
name="birthday"
147+
defaultValue={actionData?.fields?.birthday}
148+
/>
149+
{actionData?.fieldErrors?.birthday
150+
? actionData.fieldErrors.birthday.map((error, index) => (
151+
<p style={errorTextStyle} key={`birthday-error-${index}`}>
152+
{error}
153+
</p>
154+
))
155+
: null}
156+
</div>
157+
158+
<br />
159+
160+
<button type="submit" disabled={isSubmitting}>
161+
Register
162+
</button>
163+
</Form>
164+
<hr />
165+
<Link to="/products" prefetch="intent">
166+
View Products
167+
</Link>
168+
</div>
169+
);
170+
}

0 commit comments

Comments
 (0)