When I was recently setting up end-to-end tests for Lucid, I started running into an unexpected issue with form submissions.
As part of my auth.setup.ts file, the test suite walks through an OTP-based login flow and establishes a session for the rest of the tests. Simple enough.
Except it wasn’t.
Occasionally, the tests would fail because Playwright was interacting with the form before React had fully hydrated the page. Inputs weren’t ready yet, buttons weren’t interactive, and the test would crash before it even got started.
Let’s walk through how I fixed this—and how I ended up with more reusable, resilient form components along the way.
My first instinct was to wait for the page to finish loading and then add a small buffer for hydration.
await page.goto("/sign/in", { waitUntil: "networkidle" });
await page.waitForTimeout(1000);
This helped most of the time. The tests became more stable, and failures were less frequent.
But there were two big problems:
Any solution that depends on “waiting long enough” is eventually going to break.
So I kept looking.
Playwright already does something useful for us: commands like page.fill() automatically wait for elements to be visible and interactive.
So instead of slowing down the tests, I flipped the problem around:
What if the form simply wasn’t interactive until hydration finished?
If inputs and buttons are disabled until React is ready, Playwright will naturally wait.
Here’s what that looked like at first:
const [hydrated, setHydrated] = useState(false);
useEffect(() => {
setHydrated(true);
}, []);
<Button type="submit" disabled={!hydrated}>
Continue with email
</Button>
Once the component mounts, hydrated becomes true, and the form becomes usable.
This worked beautifully. I could remove the timeout entirely, and test stability improved immediately.
But there was another problem…
I didn’t want to repeat this logic in every form.
TanStack Form has excellent support for composition and custom components. That made it the perfect place to centralise this hydration logic.
Instead of sprinkling useEffect everywhere, I built reusable form components that handle hydration automatically.
First, I set up shared form and field contexts:
// components/form/context.tsx
import { createFormHookContexts } from "@tanstack/react-form";
export const {
fieldContext,
formContext,
useFieldContext,
useFormContext,
} = createFormHookContexts();
Next, I wrapped my input component with hydration logic:
// components/form/TextField.tsx
import { useEffect, useState } from "react";
import { Field, FieldDescription, FieldError, FieldLabel } from "../ui/field";
import { Input } from "../ui/input";
import { useFieldContext } from "./context";
export function TextField({
label,
description,
type = "text",
placeholder,
}) {
const [hydrated, setHydrated] = useState(false);
const field = useFieldContext<string>();
const isInvalid =
field.state.meta.isTouched && !field.state.meta.isValid;
useEffect(() => {
setHydrated(true);
}, []);
return (
<Field data-invalid={isInvalid}>
<FieldLabel htmlFor={field.name}>{label}</FieldLabel>
<Input
id={field.name}
name={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
aria-invalid={isInvalid}
placeholder={placeholder}
autoComplete="off"
type={type}
disabled={!hydrated}
/>
{description && <FieldDescription>{description}</FieldDescription>}
{isInvalid && <FieldError errors={field.state.meta.errors} />}
</Field>
);
}
Now every text field stays disabled until hydration completes.
I applied the same idea to the submit button:
// components/form/SubmitButton.tsx
import { useEffect, useState, type ReactNode } from "react";
import { Button } from "@/components/ui/button";
import { Spinner } from "@/components/ui/spinner";
import { useFormContext } from "./context";
export function SubmitButton({ children }: { children: ReactNode }) {
const [hydrated, setHydrated] = useState(false);
const form = useFormContext();
useEffect(() => {
setHydrated(true);
}, []);
return (
<form.Subscribe selector={(state) => state.isSubmitting}>
{(isSubmitting) => (
<Button type="submit" disabled={!hydrated || isSubmitting}>
{isSubmitting ? <Spinner /> : children}
</Button>
)}
</form.Subscribe>
);
}
This ensures that:
Then I wired everything up using createFormHook:
// components/form/index.tsx
import { createFormHook } from "@tanstack/react-form";
import { fieldContext, formContext } from "./context";
export const { useAppForm } = createFormHook({
fieldComponents: {
TextField,
},
formComponents: {
SubmitButton,
},
fieldContext,
formContext,
});
Now my app has a single, reusable form system that knows how to behave during hydration.
With everything in place, forms become clean and predictable:
// routes/sign.in.tsx
const form = useAppForm({
defaultValues: {
email: "",
},
validators: {
onSubmit: type({
email: "string.email",
}),
},
onSubmit: async ({ value }) => {
// ...
},
});
return (
<form
onSubmit={(e) => {
e.preventDefault();
form.handleSubmit();
}}
>
<FieldGroup>
<form.AppField
name="email"
children={(field) => (
<field.TextField
label="Email"
placeholder="your@email.com"
/>
)}
/>
<form.AppForm>
<form.SubmitButton>
Continue with email
</form.SubmitButton>
</form.AppForm>
</FieldGroup>
</form>
);
No timeouts. No hacks. No repeated hydration logic.
Playwright waits naturally, because the form only becomes interactive when it’s actually ready.
By moving hydration awareness into reusable TanStack Form components, I ended up with:
What started as a flaky test issue turned into an opportunity to improve both developer experience and user experience.
If you’re testing React apps with Playwright and running into hydration timing problems, this pattern is well worth considering.