Skip to content

Commit

Permalink
feat(react-form): support Remix SSR (#1017)
Browse files Browse the repository at this point in the history
* feat(react-form): support Remix SSR

* update deps, update createServerValidate, fix ci errors

* docs: add remix example to the docs sidebar

---------

Co-authored-by: Leonardo Montini <[email protected]>
  • Loading branch information
a-is-4-adam and Balastrong authored Nov 16, 2024
1 parent ae97ec3 commit 56d065a
Show file tree
Hide file tree
Showing 17 changed files with 2,558 additions and 2 deletions.
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

0 comments on commit 56d065a

Please sign in to comment.