form icon indicating copy to clipboard operation
form copied to clipboard

[Tracking] SSR & RSC Usage

Open juliendelort opened this issue 2 years ago โ€ข 5 comments

An increasing number of people use SSR & frameworks like NextJS (page router or app router) and Remix, so we should have a guide section covering that.

Some topics:

  • How do I use this with NextJs (we probably need separate sections for the pages router and the app router). Can I do SSR? In the case of RSCโ€™s, do I need a client component? Can I use server actions?
  • How do I use this with Remix? Remix has [a dedicated Form element]((https://remix.run/docs/en/main/components/form), are we compatible with that?

juliendelort avatar Oct 18 '23 18:10 juliendelort

Love this. Right now we have a blocking bug via #470, but this is a good addition

crutchcorn avatar Oct 18 '23 19:10 crutchcorn

Removing the good first issue flag and assigning this work to myself. I spoke with @tannerlinsley about a potential API we're investigating that looks something like this:

import { useFormState } from "react-dom";
import {
    createFormFactory,
    // Proposed API
    useTransform,
    // Proposed API
    mergeForm
} from "@tanstack/react-form";

const {
  useForm,
  // Proposed API
  validateFormData,
  // Proposed API
  // This allows us to ensure that the types of `useFormState`'s `state`
  // is the same as returned from onServerValidate without having to force the
  // user to manually replicate
  initialFormState,
} = createFormFactory({
  defaultValues: {
    name: "",
    age: 0,
  },
  // Proposed API
  onServerValidate: async (values) => {
    if (values.name.includes("server_error")) {
      return {
        name: "This is a server error",
      };
    }
  },
});

async function submitForm(formData) {
  "use server";
  const results = await validateFormData({ formData });
  if (results) return results;
}

export default function CreatePerson() {
  const [state, dispatch] = useFormState(submitForm, initialFormState);
  const form = useForm({
    /**
     * Proposed API
     *
     * Transforms under-the-hood to a non-framework agnostic:
     * { fn: formBase => mergeForm(formBase, state),
     * deps: [state] }
     *
     * Which would allow us to watch the deps changes in `form-core` and run the relevant functions ourselves
     *
     * But is a custom hook because it allows us to have auto-fixed deps without
     * writing our own ESLint plugin:
     *
     * @see https://www.npmjs.com/package/eslint-plugin-react-hooks#advanced-configuration
     */
    transform: useTransform((formBase) => mergeForm(formBase, state), [state]),
  });

  return (
    <form.Provider>
      <form action={dispatch}>
        <form.Field
          name="name"
          onChange={(val) =>
            val.includes("client_error") ? "This is a client error" : ""
          }
        >
          {(field) => (
            <>
              <label htmlFor={field.name}>First Name:</label>
              <input
                name={field.name}
                value={field.state.value}
                onBlur={field.handleBlur}
                onChange={(e) => field.handleChange(e.target.value)}
              />
              {field.state.meta.errors ? (
                <>
                  {field.state.meta.errors.map((error) => (
                    // Merge client errors and form errors
                    <div key={error}>{error}</div>
                  ))}
                </>
              ) : null}
            </>
          )}
        </form.Field>
        <button type="submit">Submit</button>
      </form>
    </form.Provider>
  );
}

Using this API, we believe we're able to support:

  • NextJS' server actions & useFormState
  • Remix server validation (I might need help with this, reach out to me or comment here please)
  • Nuxt/non-React SSR/SSG usage

And support progressively enhanced (disable JS, still see form errors via page refresh), isomorphic (client and server validation support using the same API) form valdiation.

I'd love to hear anyone's thoughts on this API or our approach.

crutchcorn avatar Oct 30 '23 07:10 crutchcorn

Just got off a call with @Fredkiss3 (Server actions superstar). Turns out we have a few problems with our potential API:

  1. You cannot declare a "use server" function in a "use client" component, despite the inline "use server" syntax
  2. useFormState requires "use client"

This means that we can change our usable API slightly to something like so, where we first define our shared state:

// form-base.ts
import {
    createFormFactory,
} from "@tanstack/react-form";

const {
  useForm,
  validateFormData,
  initialFormState,
} = createFormFactory({
  defaultValues: {
    name: "",
    age: 0,
  },
  // Proposed API
  onServerValidate: async (values) => {
    if (values.name.includes("server_error")) {
      return {
        name: "This is a server error",
      };
    }
  },
});

export {
  useForm,
  validateFormData,
  initialFormState
}

Then migrate our action to a "use server file:

"use server"

// form-action.ts
import  {
  validateFormData,
} from "./form-base";

export default async function submitForm(formData) {
  const results = await validateFormData({ formData });
  if (results) return results;
}

And finally we can use this in our client comp:

"use client"
import { useFormState } from "react-dom";
import {
    useTransform,
    mergeForm
} from "@tanstack/react-form";
import {useForm, initialFormState} from "./form-base";
import { submitForm } from "./form-action";

export default function CreatePerson() {
  const [state, dispatch] = useFormState(submitForm, initialFormState);
  const form = useForm({
    transform: useTransform((formBase) => mergeForm(formBase, state), [state]),
  });

  return (
    <form.Provider>
      <form action={dispatch}>
        <form.Field
          name="name"
          onChange={(val) =>
            val.includes("client_error") ? "This is a client error" : ""
          }
        >
          {(field) => (
            <>
              <label htmlFor={field.name}>First Name:</label>
              <input
                name={field.name}
                value={field.state.value}
                onBlur={field.handleBlur}
                onChange={(e) => field.handleChange(e.target.value)}
              />
              {field.state.meta.errors ? (
                <>
                  {field.state.meta.errors.map((error) => (
                    // Merge client errors and form errors
                    <div key={error}>{error}</div>
                  ))}
                </>
              ) : null}
            </>
          )}
        </form.Field>
        <button type="submit">Submit</button>
      </form>
    </form.Provider>
  );
}

However, there's a third issue here as well, that I'll outline in the next comment.

crutchcorn avatar Nov 04 '23 23:11 crutchcorn

@Fredkiss3 then informed me a major problem with this bit of our POC code:

const {
  // This will throw an error in a `"use server"` usage
  useForm,
  validateFormData,
  initialFormState,
} = createFormFactory({
   // ...
});

This is because when you have a setup that imports from useState (even lazily), like so:

"use client"

import { useFormState } from "react-dom"
import someAction from "./action";

export const ClientComp = () => {
  const [data, action] = useFormState(someAction, "Hello client");

  return <form action={action}>
    <p>{data}</p>
    <button type={"submit"}>Update data</button>
  </form>
}
"use server"
// action.ts

import {data} from "./shared-code";

export default async function someAction() {
  return "Hello " + data.name;
}
// shared-code.ts
import {useState} from "react";

export const data = {
  useForm: <T>(val: T) => {
      useState(val)
  },
  name: "server"
}

You'll be presented with the following error:

./src/app/shared-code.ts
ReactServerComponentsError:

You're importing a component that needs useState. It only works in a Client Component but none of its parents are marked with "use client", so they're Server Components by default.
Learn more: https://nextjs.org/docs/getting-started/react-essentials

   โ•ญโ”€[/src/app/shared-code.ts:1:1]
 1 โ”‚ import {useState} from "react";
   ยท         โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
 2 โ”‚ 
 3 โ”‚ export const data = {
 3 โ”‚   useForm: <T>(val: T) => {
   โ•ฐโ”€โ”€โ”€โ”€

Maybe one of these should be marked as a client entry with "use client":
./src/app/shared-code.ts
./src/app/action.ts

This is because NextJS statistically analyzes usage of useState to ensure it's not being utilized in a useState.

This has been written about here:

https://phryneas.de/react-server-components-controversy

And documented in this issue:

https://github.com/apollographql/apollo-client/issues/10974

It seems like there may be a workaround here:

https://github.com/apollographql/apollo-client/issues/10974#issuecomment-1594746276

But it's unclear if that's still needed per:

https://github.com/vercel/next.js/pull/56501

I'll reach out to some folks and see if there's anything else I can learn prior to prototyping and shipping

crutchcorn avatar Nov 04 '23 23:11 crutchcorn

The "safe" workaround currently suggested by the React team is

if (Object(React).useState) {

because the Object(React) will work around all static analysis right now.

To be honest, I don't feel comfortable with that workaround (another future bundler could still detect it and we'd end up in an endless cat-and-mouse game), so Apollo Client will likely use rehackt as a wrapper around React.

Small warning about that package: It has not been fully reviewed yet - reviews welcome :)


All that said: the "official" solution would be to put an exports field in your package.json and have a separate import condition for RSC where all of that code has been stripped out.

phryneas avatar Nov 05 '23 10:11 phryneas

Closing, as we have documented and working support for SSR/Server Actions

https://tanstack.com/form/latest/docs/framework/react/guides/ssr

crutchcorn avatar Mar 05 '24 02:03 crutchcorn

hi, thanks for you awesome work is for vue this the same ? beacuse there is no onServerValidate prop image

cannap avatar Apr 23 '24 15:04 cannap

@cannap, there is no Vue support for SSR TanStack Usage at this time. It should be a relatively light lift, but I just don't have experience in Nuxt

crutchcorn avatar Apr 23 '24 17:04 crutchcorn