React Hooks Pattern
This guide covers the recommended patterns for using mobx-form in React applications with hooks.
The Problem
A mobx-form model is a MobX observable. If you create it inside a component without stabilizing it, React will create a new form instance on every render — losing all state:
// ❌ BAD: creates a new form on every render
const MyForm = observer(() => {
const form = createModel({ /* ... */ });
return <input value={form.fields.name.value} />;
});
The Solution: useState with Lazy Initializer
Wrap createModel in useState with a lazy initializer. This ensures the form is created once and persists across re-renders:
import { useState } from 'react';
import { createModel } from 'mobx-form';
const useMyForm = () => {
const [form] = useState(() => createModel({
descriptors: {
name: { required: 'Name is required' },
email: { required: 'Email is required' },
},
initialState: { name: '', email: '' },
}));
return form;
};
useState instead of useRef?useState with a lazy initializer guarantees the factory runs exactly once. useRef also works, but useState signals intent more clearly — you're storing stable state, not a mutable ref.
Full Form Hook Example
Here's a complete, production-ready form hook:
import { createModel } from 'mobx-form';
import { useState } from 'react';
export type SignInFormValues = {
email: string;
password: string;
};
const getEmailValidator = (message: string) => {
return ({ value }: { value: string }) => {
if (!value.includes('@')) {
throw new Error(message);
}
};
};
export const useLoginForm = () => {
const [form] = useState(() =>
createModel<SignInFormValues>({
descriptors: {
email: {
required: 'Please enter your email',
validator: getEmailValidator('Please enter a valid email'),
waitForBlur: true,
},
password: {
required: 'Please enter your password',
waitForBlur: true,
},
},
initialState: {
email: '',
password: '',
},
}),
);
return form;
};
Key design decisions:
- Types are defined outside the hook (
SignInFormValues) for reuse - Validator factories (like
getEmailValidator) accept messages as arguments — useful for i18n waitForBlur: trueprevents premature validation while the user is still typing- The hook returns the form model directly — the consuming component decides how to render it
Using the Hook in a Component
import { observer } from 'mobx-react-lite';
import { useLoginForm } from './useLoginForm';
export const LoginForm = observer(() => {
const form = useLoginForm();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
await form.validate();
if (form.valid) {
const { email, password } = form.serializedData;
await signIn(email, password);
}
};
return (
<form onSubmit={handleSubmit}>
<div>
<label>Email</label>
<input
type="email"
value={form.fields.email.value}
onChange={(e) => form.fields.email.setValue(e.target.value)}
onBlur={() => form.fields.email.markBlurredAndValidate()}
/>
{form.fields.email.error && (
<span className="error">{form.fields.email.error}</span>
)}
</div>
<div>
<label>Password</label>
<input
type="password"
value={form.fields.password.value}
onChange={(e) => form.fields.password.setValue(e.target.value)}
onBlur={() => form.fields.password.markBlurredAndValidate()}
/>
{form.fields.password.error && (
<span className="error">{form.fields.password.error}</span>
)}
</div>
<button type="submit" disabled={form.validating}>
{form.validating ? 'Signing in...' : 'Sign In'}
</button>
</form>
);
});
With Internationalization (i18n)
When using a translation system, create the form inside the hook so it captures the current translations:
import { useTranslation } from '@/hooks/useTranslation';
import { createModel } from 'mobx-form';
import { useState } from 'react';
export const useLoginForm = () => {
const { t } = useTranslation();
const [form] = useState(() =>
createModel<SignInFormValues>({
descriptors: {
email: {
required: t.auth.enterEmail,
validator: getEmailValidator(t.auth.invalidEmail),
waitForBlur: true,
},
password: {
required: t.auth.enterPassword,
waitForBlur: true,
},
},
initialState: { email: '', password: '' },
}),
);
return form;
};
The translations are captured at form-creation time (first render). If you need dynamic messages that change after creation, consider using field.setRequired(newMessage) or managing validators separately.
Multiple Forms per Page
Each hook call creates an independent form instance:
const ProfilePage = observer(() => {
const nameForm = useNameForm();
const addressForm = useAddressForm();
const passwordForm = usePasswordForm();
// Each form validates independently
return (
<div>
<NameSection form={nameForm} />
<AddressSection form={addressForm} />
<PasswordSection form={passwordForm} />
</div>
);
});
Resetting a Form
Use restoreInitialValues() to reset all fields back to their initial state:
const handleReset = () => {
form.restoreInitialValues();
};
Or update from new data (e.g., after loading from an API):
useEffect(() => {
if (userData) {
form.updateFrom(userData);
}
}, [userData, form]);