Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(react-form): support Remix SSR #1017

Merged
merged 4 commits into from
Nov 16, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -443,6 +443,10 @@
"label": "Next Server Actions",
"to": "framework/react/examples/next-server-actions"
},
{
"label": "Remix",
"to": "framework/react/examples/remix"
},
{
"label": "UI Libraries",
"to": "framework/react/examples/ui-libraries"
Expand Down
153 changes: 152 additions & 1 deletion docs/framework/react/guides/ssr.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ Today we support the following meta-frameworks:

- [TanStack Start](https://tanstack.com/start/)
- [Next.js](https://nextjs.org/)
- [Remix](https://remix.run)

_We need help adding Remix support! [Come help us research and implement it here.](https://github.com/TanStack/form/issues/759)_

## Using TanStack Form in TanStack Start

Expand Down Expand Up @@ -306,3 +306,154 @@ Here, we're using [React's `useActionState` hook](https://unicorn-utterances.com
>
>
> [This is a limitation of Next.js](https://github.com/phryneas/rehackt). Other meta-frameworks will likely not have this same problem.

## Using TanStack Form in Remix

> Before reading this section, it's suggested you understand how Remix actions work. [Check out Remix's docs for more information](https://remix.run/docs/en/main/discussion/data-flow#route-action)

### Remix Prerequisites

- Start a new `Remix` project, following the steps in the [Remix Documentation](https://remix.run/docs/en/main/start/quickstart).
- Install `@tanstack/react-form`
- Install any [form validator](/form/latest/docs/framework/react/guides/validation#adapter-based-validation-zod-yup-valibot) of your choice. [Optional]

## Remix integration

Let's start by creating a `formOption` that we'll use to share the form's shape across the client and server.


```typescript
// routes/_index/route.tsx
import { formOptions } from '@tanstack/react-form/remix'

// You can pass other form options here, like `validatorAdapter`
export const formOpts = formOptions({
defaultValues: {
firstName: '',
age: 0,
},
})
```

Next, we can create [an action](https://remix.run/docs/en/main/discussion/data-flow#route-action) that will handle the form submission on the server.

```tsx
// routes/_index/route.tsx

import {
ServerValidateError,
createServerValidate,
formOptions
} from '@tanstack/react-form/remix'

import type { ActionFunctionArgs } from '@remix-run/node'

// export const formOpts = formOptions({

// Create the server action that will infer the types of the form from `formOpts`
const serverValidate = createServerValidate({
...formOpts,
onServerValidate: ({ value }) => {
if (value.age < 12) {
return 'Server validation: You must be at least 12 to sign up'
}
},
})

export async function action({request}: ActionFunctionArgs) {
const formData = await request.formData()
try {
await serverValidate(formData)
} catch (e) {
if (e instanceof ServerValidateError) {
return e.formState
}

// Some other error occurred while validating your form
throw e
}

// Your form has successfully validated!

}
```

Finally, the `action` will be called when the form submits.

```tsx
// routes/_index/route.tsx
import { Form, useActionData } from '@remix-run/react'

import { mergeForm, useForm, useTransform } from '@tanstack/react-form'
import {
ServerValidateError,
createServerValidate,
formOptions,
initialFormState,
} from '@tanstack/react-form/remix'

import type { ActionFunctionArgs } from '@remix-run/node'

// export const formOpts = formOptions({

// const serverValidate = createServerValidate({

// export async function action({request}: ActionFunctionArgs) {

export default function Index() {
const actionData = useActionData<typeof action>()

const form = useForm({
...formOpts,
transform: useTransform(
(baseForm) => mergeForm(baseForm, actionData ?? initialFormState),
[actionData],
),
})

const formErrors = form.useStore((formState) => formState.errors)

return (
<Form method="post" onSubmit={() => form.handleSubmit()}>
{formErrors.map((error) => (
<p key={error as string}>{error}</p>
))}

<form.Field
name="age"
validators={{
onChange: ({ value }) =>
value < 8 ? 'Client validation: You must be at least 8' : undefined,
}}
>
{(field) => {
return (
<div>
<input
name="age"
type="number"
value={field.state.value}
onChange={(e) => field.handleChange(e.target.valueAsNumber)}
/>
{field.state.meta.errors.map((error) => (
<p key={error as string}>{error}</p>
))}
</div>
)
}}
</form.Field>
<form.Subscribe
selector={(formState) => [formState.canSubmit, formState.isSubmitting]}
>
{([canSubmit, isSubmitting]) => (
<button type="submit" disabled={!canSubmit}>
{isSubmitting ? '...' : 'Submit'}
</button>
)}
</form.Subscribe>
</Form>
)
}
```

Here, we're using [Remix's `useActionData` hook](https://remix.run/docs/en/main/hooks/use-action-data) and TanStack Form's `useTransform` hook to merge state returned from the server action with the form state.
5 changes: 5 additions & 0 deletions examples/react/remix/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
node_modules

/.cache
/build
.env
40 changes: 40 additions & 0 deletions examples/react/remix/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# Welcome to Remix!

- 📖 [Remix docs](https://remix.run/docs)

## Development

Run the dev server:

```shellscript
npm run dev
```

## Deployment

First, build your app for production:

```sh
npm run build
```

Then run the app in production mode:

```sh
npm start
```

Now you'll need to pick a host to deploy it to.

### DIY

If you're familiar with deploying Node applications, the built-in Remix app server is production-ready.

Make sure to deploy the output of `npm run build`

- `build/server`
- `build/client`

## Styling

This template comes with [Tailwind CSS](https://tailwindcss.com/) already configured for a simple default starting experience. You can use whatever css framework you prefer. See the [Vite docs on css](https://vitejs.dev/guide/features.html#css) for more information.
29 changes: 29 additions & 0 deletions examples/react/remix/app/root.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import {
Links,
Meta,
Outlet,
Scripts,
ScrollRestoration,
} from '@remix-run/react'

export function Layout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<Meta />
<Links />
</head>
<body>
{children}
<ScrollRestoration />
<Scripts />
</body>
</html>
)
}

export default function App() {
return <Outlet />
}
97 changes: 97 additions & 0 deletions examples/react/remix/app/routes/_index/route.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { Form, useActionData } from '@remix-run/react'

import { mergeForm, useForm, useTransform } from '@tanstack/react-form'
import {
ServerValidateError,
createServerValidate,
formOptions,
initialFormState,
} from '@tanstack/react-form/remix'

import type { ActionFunctionArgs } from '@remix-run/node'

const formOpts = formOptions({
defaultValues: {
firstName: '',
age: 0,
},
})

const serverValidate = createServerValidate({
...formOpts,
onServerValidate: ({ value }) => {
if (value.age < 12) {
return 'Server validation: You must be at least 12 to sign up'
}
},
})

export async function action({ request }: ActionFunctionArgs) {
const formData = await request.formData()
try {
await serverValidate(formData)
} catch (e) {
if (e instanceof ServerValidateError) {
return e.formState
}

// Some other error occurred while validating your form
throw e
}

// Your form has successfully validated!
}

export default function Index() {
const actionData = useActionData<typeof action>()

const form = useForm({
...formOpts,
transform: useTransform(
(baseForm) => mergeForm(baseForm, actionData ?? initialFormState),
[actionData],
),
})
const formErrors = form.useStore((formState) => formState.errors)

return (
<Form method="post" onSubmit={() => form.handleSubmit()}>
{formErrors.map((error) => (
<p key={error as string}>{error}</p>
))}

<form.Field
name="age"
validators={{
onChange: ({ value }) =>
value < 8 ? 'Client validation: You must be at least 8' : undefined,
}}
>
{(field) => {
return (
<div>
<input
name="age"
type="number"
value={field.state.value}
onChange={(e) => field.handleChange(e.target.valueAsNumber)}
/>
{field.state.meta.errors.map((error) => (
<p key={error as string}>{error}</p>
))}
</div>
)
}}
</form.Field>
<form.Subscribe
selector={(formState) => [formState.canSubmit, formState.isSubmitting]}
>
{([canSubmit, isSubmitting]) => (
<button type="submit" disabled={!canSubmit}>
{isSubmitting ? '...' : 'Submit'}
</button>
)}
</form.Subscribe>
</Form>
)
}
30 changes: 30 additions & 0 deletions examples/react/remix/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
{
"name": "@tanstack/form-example-remix",
"private": true,
"type": "module",
"scripts": {
"build": "remix vite:build",
"dev": "remix vite:dev",
"_test:types": "tsc"
},
"dependencies": {
"@remix-run/node": "^2.14.0",
"@remix-run/react": "^2.14.0",
"@remix-run/serve": "^2.14.0",
"@tanstack/react-form": "^0.35.0",
"isbot": "^4.1.0",
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"devDependencies": {
"@remix-run/dev": "^2.11.2",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"typescript": "5.4.5",
"vite": "^5.4.10",
"vite-tsconfig-paths": "^5.1.2"
},
"engines": {
"node": ">=20.0.0"
}
}
Binary file added examples/react/remix/public/favicon.ico
Binary file not shown.
Loading