●MAX — Rork Max generates native Swift apps for iPhone, iPad, Apple Watch, Apple TV, Vision Pro, and iMessage●PUBLISH — Rork Max ships 2-click App Store publishing and runs $200/month●RN — The standard Rork builds native iOS/Android apps with React Native (Expo) — the quicker path to a working app●PRICE — Rork is free to start, with paid plans from $25/month●FUND — Rork raised $2.8M from a16z; the platform now sees 743k+ monthly visits with 85% growth●FLOW — Describe your app in plain English and Rork generates deployable code that can use the camera, notifications, and more●MAX — Rork Max generates native Swift apps for iPhone, iPad, Apple Watch, Apple TV, Vision Pro, and iMessage●PUBLISH — Rork Max ships 2-click App Store publishing and runs $200/month●RN — The standard Rork builds native iOS/Android apps with React Native (Expo) — the quicker path to a working app●PRICE — Rork is free to start, with paid plans from $25/month●FUND — Rork raised $2.8M from a16z; the platform now sees 743k+ monthly visits with 85% growth●FLOW — Describe your app in plain English and Rork generates deployable code that can use the camera, notifications, and more
Rebuilding Rork's Generated Form Screens for Real Use: react-hook-form and zod
Rork's generated forms look fine on screen but fall apart on a real device: the whole screen re-renders on every keystroke, the keyboard hides the submit button, and slow networks invite double submits. Here is how I rebuild them with react-hook-form and zod, from an indie developer's point of view.
Ask Rork to "build me a login screen" and you get a working screen in seconds. As an indie developer running wallpaper apps under the Dolice name, I often let Rork draft the settings and feedback forms for my apps. But I have never shipped one of those generated forms to the App Store as-is. The reason is simple: the generated code is a valid screen, yet it is not a tool you can comfortably use with your thumbs.
In practice, the screen flickers on every keystroke, the keyboard covers the submit button on a real iPhone, and a slow connection lets the user tap submit twice. This is less about Rork being wrong and more about the division of labor where AI lays the foundation and a human does the finishing. What follows is how I rebuild a generated form with react-hook-form and zod, along with the reasoning behind each decision.
Three ways a Rork-generated form breaks
A freshly generated form usually looks like this: one useState per field and a single validation pass when the button is pressed.
// The naive form Rork tends to output (it has problems)function LoginScreen() { const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); const [error, setError] = useState(""); const onSubmit = async () => { if (!email.includes("@")) { setError("Email format is invalid"); return; } if (password.length < 8) { setError("Password must be at least 8 characters"); return; } await signIn(email, password); // runs multiple times if tapped twice }; return ( <View style={{ padding: 16 }}> <TextInput value={email} onChangeText={setEmail} placeholder="Email" /> <TextInput value={password} onChangeText={setPassword} secureTextEntry placeholder="Password" /> {error ? <Text style={{ color: "red" }}>{error}</Text> : null} <Button title="Sign in" onPress={onSubmit} /> </View> );}
This implementation hides three problems that only surface on a real device.
The whole screen re-renders on every character
Each useState update re-renders the component that owns it (here, the entire LoginScreen). With two fields you will not notice, but as fields grow to five or six and validation and derived UI pile on, a heavy re-render fires on every keystroke and input starts to feel a beat behind. In my experience the lag becomes obvious on item-heavy forms like settings screens.
The keyboard hides the submit button
A screen that simply stacks Views gets its lower half covered when the software keyboard appears. The moment a user focuses the password field on an iPhone, the submit button disappears, and they cannot press it until they dismiss the keyboard. That extra step causes drop-off right at the finish line.
The button can be tapped twice mid-request
onSubmit is async, but nothing records that it is in flight. On a slow connection the user feels "nothing happened" and taps again. The same request runs twice, which means duplicate sign-ups for account forms and double charges for purchase flows.
Why react-hook-form and zod
Formik and hand-rolled hooks are options, but I recommend the react-hook-form plus zod pairing, for three reasons.
First, react-hook-form keeps input values close to uncontrolled internally, avoiding full-screen re-renders. Second, with zod you write the schema once and both your TypeScript types and your validation derive from the same definition, so they never drift apart. Third, both libraries are light and drop straight into the Expo project Rork hands you.
Add them at the root of your exported project:
# At the root of the project you exported from Rorknpx expo install react-hook-formnpx expo install zod @hookform/resolvers
@hookform/resolvers is the adapter that bridges a zod schema into react-hook-form. Forget it and validation never runs, so install it up front.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦Diagnose the three ways a Rork-generated form breaks on a real device (full re-render, keyboard overlap, double submit) so you know what to fix first
✦Build a reusable component that wraps each TextInput with react-hook-form's Controller and a zod schema
✦Finish a login screen to production quality, including the iOS/Android KeyboardAvoidingView gap and routing server errors back to the right field
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
Keep validation rules out of the component and isolate them as a schema. Then, when you add a rule later, you only have to look at one file.
// schemas/login.tsimport { z } from "zod";export const loginSchema = z.object({ email: z .string() .min(1, "Please enter your email") .email("Email format is invalid"), password: z .string() .min(8, "Password must be at least 8 characters"),});// Derive the form type from the schema (no second source of truth)export type LoginForm = z.infer<typeof loginSchema>;
The key point is deriving the type with z.infer. When you add a field, one line in the schema updates both the type and the validation. Keep a separate hand-written interface and it will drift eventually.
A reusable component that wraps each TextInput
react-hook-form's API assumes the web <input>, so you connect a React Native TextInput through a Controller. Writing Controller every time is verbose, so wrap it once into a reusable component that also renders its own error.
// components/ControlledInput.tsximport { Control, Controller, FieldValues, Path } from "react-hook-form";import { Text, TextInput, TextInputProps, View } from "react-native";import { forwardRef } from "react";type Props<T extends FieldValues> = { control: Control<T>; name: Path<T>; label: string;} & TextInputProps;// Forward the ref so "next" can move focus to the following fieldfunction ControlledInputInner<T extends FieldValues>( { control, name, label, ...rest }: Props<T>, ref: React.Ref<TextInput>) { return ( <Controller control={control} name={name} render={({ field: { onChange, onBlur, value }, fieldState: { error } }) => ( <View style={{ marginBottom: 16 }}> <Text style={{ marginBottom: 6, fontWeight: "600" }}>{label}</Text> <TextInput ref={ref} value={value} onChangeText={onChange} onBlur={onBlur} style={{ borderWidth: 1, borderColor: error ? "#e5484d" : "#d0d0d0", borderRadius: 8, padding: 12, }} {...rest} /> {/* Put the error directly under its field, not lumped at the bottom */} {error ? ( <Text style={{ color: "#e5484d", marginTop: 4, fontSize: 13 }}> {error.message} </Text> ) : null} </View> )} /> );}export const ControlledInput = forwardRef(ControlledInputInner) as <T extends FieldValues>( props: Props<T> & { ref?: React.Ref<TextInput> }) => React.ReactElement;
I use forwardRef here to enable the "press return on email, jump to password" focus move covered in the next section. Placing the error directly under each field is also deliberate: a design that collects all errors at the bottom makes it impossible to trace which field is wrong by thumb.
Rebuilding the login screen to production quality
With the parts in place, assemble the screen. Re-render suppression, keyboard avoidance, focus movement, and double-submit prevention all go in at once.
// screens/LoginScreen.tsximport { useRef } from "react";import { KeyboardAvoidingView, Platform, ScrollView, TextInput, TouchableOpacity, Text,} from "react-native";import { useForm } from "react-hook-form";import { zodResolver } from "@hookform/resolvers/zod";import { loginSchema, type LoginForm } from "../schemas/login";import { ControlledInput } from "../components/ControlledInput";export function LoginScreen() { const passwordRef = useRef<TextInput>(null); const { control, handleSubmit, setError, formState: { isSubmitting }, } = useForm<LoginForm>({ resolver: zodResolver(loginSchema), defaultValues: { email: "", password: "" }, mode: "onTouched", // validate a field only after it has been touched and blurred }); const onSubmit = async (values: LoginForm) => { try { await signIn(values.email, values.password); } catch (e) { // Route a server-side error back to the relevant field setError("password", { type: "server", message: "Email or password is incorrect", }); } }; return ( <KeyboardAvoidingView style={{ flex: 1 }} behavior={Platform.OS === "ios" ? "padding" : undefined} > <ScrollView contentContainerStyle={{ padding: 16 }} keyboardShouldPersistTaps="handled" > <ControlledInput control={control} name="email" label="Email" placeholder="you@example.com" autoCapitalize="none" keyboardType="email-address" returnKeyType="next" onSubmitEditing={() => passwordRef.current?.focus()} /> <ControlledInput ref={passwordRef} control={control} name="password" label="Password" secureTextEntry returnKeyType="done" onSubmitEditing={handleSubmit(onSubmit)} /> <TouchableOpacity onPress={handleSubmit(onSubmit)} disabled={isSubmitting} // disable while submitting to block double taps style={{ backgroundColor: isSubmitting ? "#9aa0a6" : "#1a73e8", borderRadius: 8, padding: 14, alignItems: "center", }} > <Text style={{ color: "#fff", fontWeight: "700" }}> {isSubmitting ? "Submitting…" : "Sign in"} </Text> </TouchableOpacity> </ScrollView> </KeyboardAvoidingView> );}
This code resolves all three failure modes. Input values live inside react-hook-form, so typing no longer re-renders the whole screen. isSubmitting disables the button, so taps during the request never land. The KeyboardAvoidingView and ScrollView together let the user scroll to the submit button even with the keyboard up.
Why mode: "onTouched"
Set mode to onChange and validation fires on every character, flashing "Email format is invalid" in red while the user is still typing — it feels like being rushed. With onSubmit alone, the user only learns about errors after submitting. onTouched (validate a field only after it is focused and then blurred) is the middle ground that neither rushes the user nor causes much rework, and I make it my default.
Closing the iOS / Android KeyboardAvoidingView gap
KeyboardAvoidingView does not behave the same way on iOS and Android. Copy it without understanding this and the submit button stays hidden on one platform.
Item
iOS
Android
Recommended behavior
padding
undefined (OS adjusts automatically)
When there is a header
Set keyboardVerticalOffset to the header height
Usually not needed
Soft input mode
No effect
adjustResize is assumed
On Android the app.json setting matters. If the app.json Rork outputs has no soft input mode, add this:
On screens with a header (navigation bar), if you do not pass keyboardVerticalOffset on iOS, the layout shifts up too much or too little by the combined header-and-keyboard height. Passing a measured height from useHeaderHeight() in @react-navigation/elements is the reliable fix. I cover these finer adjustments separately in fixing a keyboard that covers the input field.
Returning server errors to the right place
Even after client validation passes, the server will reject some submissions: the email is already taken, the password appears in a breach list, and so on. Showing these in a red banner at the top of the screen pulls the eye away from the offending field, which I prefer to avoid.
react-hook-form's setError lets you route a server error back to a specific field — that is what setError("password", ...) did in the earlier onSubmit. If your API returns per-field errors, you can map them mechanically:
// When the server returns an array of { field: "email", message: "..." }try { await signUp(values);} catch (e) { const fieldErrors = parseServerErrors(e); // [{ field, message }] for (const fe of fieldErrors) { setError(fe.field as keyof LoginForm, { type: "server", message: fe.message, }); }}
Now server-side errors appear with the same look and in the same spot under the same field as client validation. From the user's view, wherever the rejection came from, the place to fix is obvious. The same refactoring mindset applies before shipping any generated code, as in refactoring Rork-generated code to production quality.
What to leave to Rork and what to fix by hand
From the way I run my own apps as an indie developer, the fastest path for forms is to let Rork produce the skeleton and finish the input experience by hand. Layout, color, and labels are fine to leave to generation. But re-rendering, the keyboard, double submits, and routing server errors back are exactly the things you only discover by moving your thumb on a real device, and they are worth opening the code and fixing yourself.
For your next step, pick the form screen with the most fields in your current app and convert it to this ControlledInput. Once one screen is done, the same template fixes the rest quickly. If you hit dropped characters with Japanese input, pair this with fixing dropped characters and cursor jumps in TextInput, and the input layer will feel settled. Thank you for reading.
Share
Thank You for Reading
Rork Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.