- Motivation
- Setup
- Usage
- Connect to view via useUnit
- useField
- Form state
- Submit Filter
- Set form
- Set initial form
- Validation triggers
- Interdependent validations
- Usage with domain
- Rules
- Use rules factory
- isDirty & isTouched
- Show errors
- Use external validators lib
- Add custom error manually
- Bulk errors
- Validate by external source
- Validate manually
- Reset form
- Reset values only
- Reset errors
- Register form (full example)
- Typescipt users tips
- Advanced
- Effector 21
If you working with forms in an Effector-application, there is often a lot of boilerplate code, such as:
exportconstemailChanged=login.event()exportconstpasswordChanged=login.event()exportconstsubmitForm=login.event()exportconst$email=login.store('')exportconst$password=login.store('')exportconst$form=combine({password: $password,email: $email,})$email.on(emailChanged,(_,email)=>email)$password.on(passwordChanged,(_,password)=>password)If you also need validation, it really hurts. This library was created to improve the "form experience" in an effector application by generating form state from declarative configuration.
The library comes with hooks for react/react-native, however you can use it with VueJS or Forest as well (in the case of VueJS, you have to connect it to the view layer yourself).
Good typescript support! ❤️ ✌️ 👍
If you are using SSR, add effector-forms to the babel plugin config
{
"plugins": [
[
"effector/babel-plugin",
{
"factories": [
"effector-forms"
]
}
]
],
}Model:
import{restore,sample,createEffect}from"effector"import{createForm}from'effector-forms'exportconstloginForm=createForm({fields: {email: {init: "",// field's store initial valuerules: [{name: "email",validator: (value: string)=>/\S+@\S+\.\S+/.test(value)},],},password: {init: "",// field's store initial valuerules: [{name: "required",validator: (value: string)=>Boolean(value),}],},},validateOn: ["submit"],})exportconstloginFx=createEffect()sample({clock: loginForm.formValidated,target: loginFx,})The createForm factory creates stores and events of the form and binds them by declarative configuration. formValidated effector event will be triggered when the form is submitted (if all its fields are valid). Option validateOn sets an array of triggers by which the form values will be validated. Possible conditions: submit, blur, change. The value of each field and validation errors are stored in effector stores.
After we have created the form, we can connect it to the view using the useForm hook:
import{useForm}from'effector-forms'import{useUnit}from'effector-react'import{loginForm,loginFx}from'../model'exportconstLoginForm=()=>{const{ fields, submit, eachValid }=useForm(loginForm)constpending=useUnit(loginFx.pending)constonSubmit=(e)=>{e.preventDefault()submit()}return(<formonSubmit={onSubmit)}><inputtype="text"value={fields.email.value}disabled={pending}onChange={(e)=>fields.email.onChange(e.target.value)}/><div>{fields.email.errorText({"email": "you must enter a valid email address",})}</div><inputtype="password"value={fields.password.value}disabled={pending}onChange={(e)=>fields.password.onChange(e.target.value)}/><div>{fields.password.errorText({"required": "password required"})}</div><buttondisabled={!eachValid||pending}type="submit">
Login
</button></form>)}The effector-forms entities implement the @@unitShape protocol! This means that instead of useForm and useField hooks, you can connect the form to the view via "useUnit".
React's example:
import{useUnit}from"effector-react"import{createForm}from'effector-forms'constloginForm=createForm({fields: {email: {init: "",rules: [],},password: {init: "",rules: [],},},validateOn: ["submit"],})constLoginForm=()=>{constemail=useUnit(loginForm.fields.email)constpassword=useUnit(loginForm.fields.password)constform=useUnit(loginForm)constsubmitHandler=(e)=>{e.preventDefault()form.submit()}return(<formonSubmit={submitHandler}><inputtype="text"value={email.value}onChange={(e)=>email.onChange(e.target.value)}/><inputtype="password"value={password.value}onChange={(e)=>password.onChange(e.target.value)}/></form>)}import{createForm,useField}from'effector-forms'exportconstform=createForm({fields: {email: {init: "",rules: emailRules,},password: {init: "",rules: passwordRules,},},})constEmail=()=>{const{ value, onChange }=useField(form.fields.email)return(<inputtype="text"placeholder="email"value={value}onChange={(e)=>onChange(e.target.value)}/>)}constPassword=()=>{const{ value, onChange }=useField(form.fields.password)return(<inputtype="password"placeholder="password"value={value}onChange={(e)=>onChange(e.target.value)}/>)}constForm=()=>{constonSubmit=(e)=>{e.preventDefault()form.submit()}<formonSubmit={onSubmit}><Email/><Password/><buttontype="submit">Login</button></form>}You can access the state of fields, form and its events directly in the model:
constform=createForm(formConfig)form.fields.email.$value.watch(()=>{console.log("username field changed")})form.fields.email.$errors.watch((errors)=>{console.log(errors)})form.$values.watch(()=>{console.log("form changed")})form.$eachValid.watch((eachValid)=>{if(eachValid){console.log("form valid")}})form.fields.email.onChange("value")sample({clock: form.fields.email.onChange,target: someEvent,})Sometimes we need to prevent form submission conditionally, for example if we have an error from the server. To do this, you can use the filter option by passing a boolean Store:
exportconstloginFx=createEffect()const$serverError=restore(loginFx.failData,null)exportconstloginForm=createForm({filter: $serverError.map((error)=>error===null),
fields,validateOn: ["submit"],})$serverError.reset(form.$values.updates)sample({clock: form.formValidated,target: loginFx,})In this example the formValidated event will be triggered after submit only if the $serverError is null & form is valid.
It is often necessary to set the value of the form by an event. This is useful for initializing initial field values. In this case you can use setForm event:
typeUser={username: stringabout?: stringbirhDate?: string}constgetUserProfileFx=createEffect<void,User,Error>()constform=createForm({fields: {username: {init: ""asstring,},about: {init: ""asstring,},birthDate: {init: nullasstring|null,},},})sample({clock: getUserProfileFx.doneData,target: form.setForm,})In case you get values from the backend, use the form.setInitialForm event instead of form.setForm. This event changes both the value and the initial values. The $isDirty flag will be false.
You can validate different form fields against different triggers:
constform=createForm({validateOn: ["submit"],fields: {email: {init: "",rules: emailRulesArr,validateOn: ["blur"],},username: {init: "",rules: usernameRulesArr,},password: {init: "",rules: passwordRulesArr,validateOn: ["change"],},},})constRegisterForm=()=>{const{ submit, fields }=useForm(form)constonSubmit=(e)=>{e.preventDefault()submit()}return(<formonSubmit={onSubmit}><inputtype="text"placeholder="email"value={fields.email.value}onBlur={()=>fields.email.onBlur()}onChange={(e)=>fields.email.onChange(e.target.value)}/><inputtype="text"placeholder="username"value={fields.username.value}onChange={(e)=>fields.username.onChange(e.target.value)}/><inputtype="password"placeholder="password"value={fields.password.value}onChange={(e)=>fields.password.onChange(e.target.value)}/><buttontype="submit">Register</button></form>)}In this example:
- the email field will be validated on blur and submit
- the password field will be validated on change and submit
- the username field will be validated on submit only
Note that you must directly trigger the onBlur event on the email field. This is due to the fact that the form state is separated from the view, and the form state "knows nothing" about input field event.
You can also pass multiple triggers:
constform=createForm({validateOn: ["submit","blur"],fields: formFields,})It so happens that to validate a field, you need to know the value of another field. In this case you can use the second argument of the validator function:
constform=createForm({fields: {email: {init: "",rules: emailRules,},password: {init: "",rules: passwordRules,},confirmation: {init: "",rules: [{name: "confirmation",validator: (confirmation,{ password })=>confirmation===password},],},}})If you need to have all units of a form (fields stores and events) in a domain, you can pass the domain option. This can be useful for SSR.
constmyDomain=createDomain()constform=createForm({domain: myDomain,fields: formFields,})Validation rule is an object with name and validator properties:
{name: "required",validator: (value)=>Boolean(value),}To reuse validation rules, put them in a separate module. Very often the validator needs a parameter. For this reason, we recommend using "factory-style" when creating your validators library:
exportconstrules={required: (): Rule<string>=>({name: "required",validator: (value)=>Boolean(value),}),email: (): Rule<string>=>({name: "email",validator: (value)=>/\S+@\S+\.\S+/.test(value)}),minLength: (min: number): Rule<string>=>({name: "minLength",validator: (value)=>value.length>=min}),maxLength: (max: number): Rule<string>=>({name: "maxLength",validator: (value)=>value.length<=max}),}import{rules}from'my-validation-rules'constform=createForm({fields: {email: {init: "",rules: [rules.email(),],},password: {init: "",rules: [rules.required(),rules.minLength(3),],},},})Sometimes you need to calculate validation rules based on the current state of the form. In this case, you can pass a factory function to rules:
constform=createForm({fields: {needNotification: {init: false,rules: [],},email: {init: ""asstring,rules: (value: string,form)=>form.needNotification ? [email()] : [],},},validateOn: ["submit"],})Each field has two boolean stores: $isTouched and $isDirty.
$isTouched true if this field has ever changed (the onChange event has been called at least once). false otherwise.
isDirty true if the current value is different from the initial (===). false otherwise.
Both fields are reset on form.reset event.
constform=createForm({fields: {email: {init: "email@example.com",rules: [rules.required(),],},}})form.fields.email.onChange("")console.log(form.fields.email.$isTouched.getState())// trueconsole.log(form.fields.email.$isDirty.getState())// trueform.fields.email.onChange("email@example.com")console.log(form.fields.email.$isTouched.getState())// trueconsole.log(form.fields.email.$isDirty.getState())// falseform.reset()console.log(form.fields.email.$isTouched.getState())// falseconsole.log(form.fields.email.$isDirty.getState())// falseYou can use two different approaches to output error text
- Define the error text in the validator function
- Define error text in the view layer (components)
In the first approach returns an object instead of a boolean from the validator function:
construles={required: (): Rule<string>=>({name: "required",validator: (value)=>({isValid: Boolean(value),errorText: "Required field",}),}),}constform=createForm({fields: {username: {init: "",rules: [rules.required(),],}}})The error text can be displayed using the errorText helper:
constForm=()=>{const{ fields, hasError, errorText }=useForm(form)return(<form><inputtype="text"placeholder="username"className={hasError("username") ? "invalid" : ""}value={fields.username.value}onChange={(e)=>fields.username.onChange(e.target.value)}/><divclass="error-text">{errorText("username")}</div></form>)}Alternatively, you can define the error text by passing the second argument to the errorText helper:
construles={required: (): Rule<string>=>({name: "required",validator: (value)=>Boolean(value),}),}constform=createForm({fields: {username: {init: "",rules: [rules.required(),],}}})constForm=()=>{const{ fields, hasError, errorText }=useForm(form)return(<form><inputtype="text"placeholder="username"className={hasError("username") ? "invalid" : ""}value={fields.username.value}onChange={(e)=>fields.username.onChange(e.target.value)}/><divclass="error-text">{errorText("username",{"required": "username field is required"})}</div></form>)}You can also combine both approaches by overriding errors of only certain rules in the second argument errorText.
You can implement your own validation rules or wrap an external rules library, for example validator.js
import{Rule}from'effector-forms'importisEmailfrom'validator/lib/isEmail'functioncreateRule<Value>(name: string,validator: (v: any)=>boolean): Rule<Value>=>({name,validator})exportconstrules={email: =()=>createRule("email",isEmail)}yup validation wrapper:
import*asyupfrom'yup'import{Rule}from"effector-forms";exportfunctioncreateRule<V,T=any>({
schema,
name,}: {schema: yup.SchemaOf<T>,name: string}): Rule<V>{return{
name,validator: (v: V)=>{try{schema.validateSync(v)return{isValid: true,value: v,}}catch(err){return{isValid: false,value: v,errorText: err.message,}}},}}use it with your form:
import*asyupfrom'yup'import{createForm}from'effector-forms'import{createRule}from'@/lib/create-yup-rule'constform=createForm({fields: {email: {init: "",rules: [createRule<string>({name: 'email',schema: yup.string().email().required(),})],},password: {init: "",rules: [createRule<string>({name: "password",schema: yup.string().required().min(3),})],},},})Sometimes you need to add an error manually. To do this, you can use the addError event on the field:
constform=createForm({fields: {username: {init: "",},},})constsomethingHappened=createEvent()sample({clock: somethingHappened.map(()=>({rule: "my-custom",errorText: "somethingHappened",})),target: form.fields.addError,})you can use this to add a server error to the field:
constloginFx=createEffect<{email: string,password: string},void,Error>()constloginForm=createForm({fields: {email: {init: "",},password: {init: "",},},})sample({source: loginFx.failData.map((error)=>error.name==="already-exists" ? {rule: "already-exists"} : null),filter: Boolean,target: loginForm.fields.email.addError,})You can add multiple errors for many fields at once with the form.addErrors event:
sample({clock: loginFx.failData.map((errors)=>errors.map((err)=>({field: err.field,rule: "backend",errorText: err.msg,}))),filter: (errors)=>errors.length>0,target: loginForm.addErrors,})You can pass external store to the validation rule. This storage will be available in the validator function:
constloginForm=createForm({fields: {email: {init: "",rules: [{name: "required_if",source: $needToValidate,validator: (value,form,needToValidate)=>{if(!needToValidate)returntruereturnBoolean(value)}},],},},})Use the validate event to manually validate the field at any time:
constform=createForm({fields: {username: {init: "",},},})constsomethingHappened=createEvent()sample({clock: somethingHappened,target: form.fields.username.validate,})Validate all fields:
sample({clock: somethingHappened,target: form.validate,})You can reset some field value or all fields:
constform=createForm(formConfig)// reset specific fieldform.fields.email.reset()// reset formform.reset()form.reset event resets both values and errors
constform=createForm(formConfig)form.resetValues()You can manually reset any field validation errors:
constform=createForm({fields: {email: {init: "",rules: emailRules,},},})form.fields.username.onChange("invalid email")form.submit()form.fields.username.resetErrors()Reset forms errors (all fields):
constform=createForm({fields: {email: {init: "",rules: emailRules,},password: {init: "",rules: passwordRules,},},})form.fields.username.onChange("invalid email")form.fields.username.onChange("invalid password")form.resetErrors()// clear all errorsRules:
import{Rule}from'effector-forms'exportconstrules={required: (): Rule<string>=>({name: "required",validator: (value)=>Boolean(value),}),email: (): Rule<string>=>({name: "email",validator: (value)=>/\S+@\S+\.\S+/.test(value)}),minLength: (min: number): Rule<string>=>({name: "minLength",validator: (value)=>value.length>=min}),}Model:
import{restore,createEffect}from'effector'import{createForm}from'effector-forms'import{rules}from'@/validation-rules'exportconstregisterFx=createEffect<{email: string,password: string},void,Error>()const$registerError=restore(registerFx.failData,null)exportconstregisterForm=createForm({fields: {email: {init: "",rules: [rules.email(),],validateOn: ["blur"],},password: {init: "",rules: [rules.required(),rules.minLength(3),],},confirm: {init: "",rules: [{name: "passwords-equal",validator: (value: string,{ password })=>{returnvalue===password},},],validateOn: ["change"],},},validateOn: ["submit"],})$registerError.reset(registerForm.$values.updates)sample({clock: registerForm.formValidated,target: registerFx,})View:
import{useUnit}from'effector'import{useForm}from'effector-forms'import{registerForm,registerFx}from'../model'constRegisterForm=()=>{constpending=useUnit(registerFx.pending)const{ submit, fields, eachValid }=useForm(form)constonSubmit=(e)=>{e.preventDefault()submit()}return(<formonSubmit={onSubmit}><inputtype="text"placeholder="email"value={fields.email.value}onBlur={()=>fields.email.onBlur()}disabled={pending}onChange={(e)=>fields.email.onChange(e.target.value)}/><inputtype="password"placeholder="password"value={fields.password.value}disabled={pending}onChange={(e)=>fields.password.onChange(e.target.value)}/><inputtype="text"placeholder="confirm"value={fields.confirm.value}disabled={pending}onChange={(e)=>fields.confirm.onChange(e.target.value)}/><buttontype="submit"disabled={pending||!eachValid}>
Register
</button></form>)}Types for stores and events generated by the createForm factory are inferred by the passed form configuration. Type inference is based on "init" field type and validator type. If you do not specify the type of the argument in the validator, the type of the resulting field may not be desired. For example:
constform=createForm({fields: {username: {init: "",rules: [{name: "required",validator: (value)=>Boolean(value),},],}},})const$usernameVal=form.fields.username.$valuethe type of $usernameVal is Store <"">, not Store<string>. This happens because the empty string is inferred as a "string literal", not a "string".
To avoid such problems, cast init to the desired type or define the type of validator first argument:
constform=createForm({fields: {username: {init: ""asstring,rules: [{name: "required",validator: (value)=>Boolean(value),},],}},})const$usernameVal=form.fields.username.$value// Store<string>constform=createForm({fields: {username: {init: "",rules: [{name: "required",validator: (value: string)=>Boolean(value),},],}},})const$usernameVal=form.fields.username.$value// Store<string>By default, createForm factory creates all form units (stores & events). Sometimes there is a need to pass externally created form units. In this case, createForm just binds them and creates combine units:
import{createForm,ValidationError}from'effector-forms'constform=createForm({fields: {email: {init: "",rules: [],units: {// all units are optional$value: createStore(""),$errors: createStore<ValidationError<string>[]>([]),$isTouched: createStore<boolean>(false),onChange: createEvent<string>(),changed: createEvent<string>(),onBlur: createEvent<void>(),addError: createEvent<{rule: string;errorText?: string}>(),validate: createEvent<void>(),reset: createEvent<void>(),resetErrors: createEvent<void>(),},},},units: {// all units are optionalsubmit: createEvent(),reset: createEvent(),resetTouched: createEvent(),formValidated: createEvent<{email: string}>(),setForm: createEvent<{email?: string}>(),},validateOn: ["submit"],})Effector 21 is no longer supported. For Effector 21 we recommend using version 0.0.24.
npm i effector-forms@0.0.24
For effector-forms v0.0.24 together with effector 21 use import
import{createForm}from"effector-forms/legacy"For effector-forms < 0.0.24 with effector 21, use the normal import:
import{createForm}from"effector-forms"SSR support with effector 21 is not available.