Recipes
Small, common situations, each self-contained. Every snippet assumes the model from the overview: a form converted with formToEffector, an association between a Virentia scope and a forked Effector scope, and a run to trigger inside. Once that pairing exists, the model is just Effector — $-stores, events, effects, and the fields lens — so these recipes are plain sample / combine / allSettled graphs.
const model = formToEffector(form);
associate({ virentia: vScope, effector: eScope });Submit on a clock
submit is an effect. Point any UI or Effector clock at it, then react to the lifecycle events instead of awaiting the call site.
import { createEvent, sample } from "effector";
const submitClicked = createEvent();
sample({ clock: submitClicked, target: model.submit });
// validated: the form passed validation, payload is the values
model.validated.watch((values) => console.log("valid", values));
// submitted: the form's onSubmit ran to completion
model.submitted.watch((values) => console.log("submitted", values));validationFailed fires instead of validated when validation rejects, and validatedAndSubmitted fires once both have happened.
Gate a submit button
Derive the button's disabled flag from the top-level stores. $isValid reflects the current errors, $isValidationPending is true while async validators run.
import { combine } from "effector";
const $canSubmit = combine(
model.$isValid,
model.$isValidationPending,
(isValid, isPending) => isValid && !isPending,
);Read $canSubmit in your UI binding. It updates inside the associated scope, so in tests read it with scope.getState($canSubmit).
Backend errors into the outer channel
Validation errors and backend errors live in separate channels so neither erases the other — see error channels. After a server rejection, write the response into the outer channel with fill({ errors }); local validators only ever touch the inner channel, so the server message stays put until you clear it.
import { allSettled, sample } from "effector";
// on a rejected submit effect, forward the server payload
sample({
clock: saveArticleFx.failData,
fn: (error) => ({ errors: { email: error.fieldErrors.email } }),
target: model.fill,
});
// or drive it directly in a test / handler
await allSettled(model.fill, {
scope: eScope,
params: { errors: { email: "Taken" } },
});Clear the outer channel — on the next edit, say — with clearOuterErrors:
sample({ clock: model.changed, target: model.clearOuterErrors });Mirror a field's value into your own store
A leaf field exposes its value store as a watch action. Fold it into a plain Effector store when you want to derive from it outside the form.
import { createStore } from "effector";
const $email = createStore("").on(
model.fields.email.state.clock(),
(_, value) => value,
);See field for the full leaf lens (state, change, setOuterError).
Drive a field from an external event
change is a target action, so an external event can push straight into the field's value. The form stays the source of truth — this goes through its change pipeline, not around it.
import { sample } from "effector";
const emailReceived = createEvent<string>();
sample({ clock: emailReceived, target: model.fields.email.change.target() });To attach a backend error to one field instead, target model.fields.email.setOuterError.target().
Reset and snapshot
reset returns the form to its initial values; forceUpdateSnapshot makes the current values the new baseline. $isChanged compares live values against that snapshot.
import { allSettled } from "effector";
// discard edits
await allSettled(model.reset, { scope: eScope });
// accept the current values as the new clean state
await allSettled(model.forceUpdateSnapshot, { scope: eScope });
model.$isChanged.watch((changed) => console.log("dirty:", changed));A common pairing: after a successful save, rebase the snapshot so the form reads as clean again.
sample({ clock: model.submitted, target: model.forceUpdateSnapshot });Prune empty array items
An array-field lens is a collection keyed by a stable id. Select the items you want with where, then delete() returns an event you can trigger. Here we drop every blank phone before submit.
import { sample } from "effector";
const prune = model.fields.phones.where((p) => !p.value).delete();
sample({ clock: cleanupClicked, target: prune });See array field for the full collection lens — ids, where, first / last / single, and delete.