State Transitions
Many entities have a status-style enum field, i.e. BookAdvance.status that moves from Pending to Signed to Paid, basically forming a state machine.
Joist has three config methods to model these state machines:
| Method | Answers |
|---|---|
config.setTransitions |
Which changes are possible? |
config.addTransitionRule |
When is a possible change allowed? |
config.addTransitionReaction |
What happens after a change? |
Quick Example
Section titled “Quick Example”Here’s an example modeling a book advance’s AdvanceStatus enum, i.e. whether the advance has/has not been paid to the author:
import { bookAdvanceConfig as config } from "./entities";
// Declares the allowed state transitionsconfig.setTransitions("status", { Pending: ["Signed"], // Allow Signed back to Pending for signature revoking Signed: ["Paid", "Pending"], // Paid is the terminal state Paid: [],});
// Prevent moving to Paid when the book is `Unpublished`config.addTransitionRule("status", { to: "Paid" }, "book", (ba) => { if (ba.book.get.title === "Unpublished") { return "Cannot pay an advance for an unpublished book"; }});
// When we're paid & data is almost committed (we're still in the txn),// schedule our payment jobconfig.addTransitionReaction("status", { to: "Paid", phase: "commit" }, (ba, ctx) => { return addPaymentJob(ctx, ba);});Declaring allowed changes
Section titled “Declaring allowed changes”config.setTransitions(field, table) maps each “from” state to the “to” states it may transition to.
A state that is missing from the table, or maps to [], is treated as a terminal state & cannot be changed.
The field’s setter immediately validates state changes, so an invalid transition immediately throws an error like Cannot change status from Paid to Pending, without waiting for em.flush.
Creating an entity is not a change, so a new entity may start in any state. If you want to prevent this, you can use a regular addRule validation rule.
Guarding changes
Section titled “Guarding changes”config.addTransitionRule(field, match, hint?, rule) checks whether to allow each state transition that its match parameter matches against.
Like addRule validation rules, it returns an error message to reject the change.
Unlike addRules, which are ran as the final phase of em.flush after all changes have settled, guards are ran immediately before addTransitionReaction callbacks, so that they can evaluate “should we have allowed this change state to happen?”.
Note that technically this is after the state change was made, during the reactions phase em.flush, so it might have to use changes or originalValues to evaluate any “existing/previous state” business logic.
Also unlike regular addRules, guards do not run on creation, because there is no from previous state. Use addRule for rules about an entity’s starting state.
Reacting to changes
Section titled “Reacting to changes”config.addTransitionReaction(field, match, hint?, fn) runs fn for each state transition that its match parameter matches against.
The default behavior is for addTransitionReaction callbacks to run whenever reactions are recalculated, either during an explicit em.recalc, or waiting for em.flush. Alternatively, see the phase param below, to run during the flush’s commit phase.
Like reactions, the transititon’s fn lambda can change any entity, and those changes can trigger more transitions in the same flush.
Unlike addReaction, the hint is a “just load hint”, as in beforeFlush, so it is used to preload data before fn is invoked, but data referenced by the hint itself does not trigger the fn.
Other behavior:
-
Each entity’s transitions fire once, in order. Different entities are processed in parallel waves, with guards finishing before flush-phase callbacks begin in each wave. This lets hinted data loads batch across entities.
-
Creation fires by default. Creating an entity with a matching
tostate firesfn, because entering a state by creation usually needs the same side effects as entering it by a change.To avoid this, you can either pass
onCreate: false, or set afromstate, as thefromclauses never match on creation. -
Only allowed transitions will fire
addTransitionReaction. If the table or a guard rejects a change,fndoesn’t run for it. -
phase: "commit"runsfninbeforeCommit, after the entities’ SQL changes have been flushed to the database, once for each matching transition. Use it for enqueueing jobs.
The match argument
Section titled “The match argument”addTransitionRule takes GuardTransitionMatch, while addTransitionReaction takes OnTransitionMatch:
interface GuardTransitionMatch<V> { // The state before the transition, omitted means any state from?: V | readonly V[]; // The state after the transition, omitted means any state to?: V | readonly V[];}
interface OnTransitionMatch<V> extends GuardTransitionMatch<V> { // Fire when an entity is created with a matching `to` state, defaults to true onCreate?: boolean; // "commit" runs in beforeCommit, after the entities' SQL changes have been flushed to the database phase?: "flush" | "commit";}New Entity Behavior
Section titled “New Entity Behavior”It’s common for a new entity to be created with a default state and then immediately be mutated to its correct initial state.
Joist recognizes this pattern and doesn’t start recording from transitions until the first reaction
pass, whether triggered by em.flush or em.recalc. So in this scenario:
// Create a new advance, initially as Pendingconst ba = em.create(BookAdvance, { status: AdvanceStatus.Pending, book, publisher });// While still creating the new entity, mark it as paidba.status = AdvanceStatus.Paid;await em.flush();Joist treats this as creation in Paid, rather than a Pending → Paid transition. No guards run, but addTransitionReaction callbacks that match creation in Paid (i.e. to Paid, with no from) still fire.
This behavior is also beneficial for both em.findOrCreate and test factories, where it’s common for an entity to be created with an initial/default status, but then very quickly set “to the right initial value”, which should not be considered a true transition change.
Design Rationale
Section titled “Design Rationale”In terms of “earning their keep”, we’ve justified implementing transitions as a first-class Joist feature because:
-
Reactions and validation rules fundamentally observe “the state of the entity right now”, and for flush cycles that trigger multiple state changes, we usually want a strict log of “the status was pending then signed then paid”, and to make sure we trigger guards & transitions for each step.
Doing this bookkeeping by hand would be tedious.
-
Usually validation rules don’t run until the last phase of
em.flush, but flush cycles that trigger multiple state changes benefit fromaddTransitionRules running as part of reactions.
Modeling Tip
Section titled “Modeling Tip”Joist doesn’t order reactions and transitions when invoking them, so if a status change triggers multiple reactions & transitions that are actually dependent on each other, they will race each other and likely cause bugs.
The best way to solve these race conditions is give each step its own state, and represent the dependency between each state as a first-class notion directly in your domain model.
For example, an Approval entity might currently have an initial Pending state that needs both:
- Create its list of
Approvers, and - Move itself to
Approvedonce everyApproverhas approved.
If both of these watch the single Approval Pending state change, the second reaction might run before the first one has created any
approvers, see that “every approver has approved” (because there are none), and approve too early.
We can fix this by modeling each step as its own state, so each piece of logic knows when to run:
// Split the old singular `Pending` state into two: `Opening` and `PendingDecision`config.setTransitions("status", { Opening: ["PendingDecision"], PendingDecision: ["Approved", "Rejected"],});
// Opening only prepares the approvers, and then hands off to PendingDecisionconfig.addTransitionReaction("status", { to: "Opening" }, (approval) => { createApprovers(approval); approval.status = ApprovalStatus.PendingDecision;});
// PendingDecision only watches the approvers, which now existconfig.addReaction({ status: {}, approvers: "status" }, (approval) => { if (!approval.isPendingDecision) return; if (approval.approvers.get.every((a) => a.isApproved)) approval.status = ApprovalStatus.Approved;});Now our the addTransitionReaction and addReaction know exactly when each should run.
Inheritance
Section titled “Inheritance”Like validation rules, transition configuration is inherited by entity subtypes. Subtype tables and guards add restrictions to the base type’s configuration, and both base and subtype callbacks run for matching transitions.
Test Factories
Section titled “Test Factories”By default, test factories that set status: Paid still trigger the addTransitionReactions that have a to: Paid match.
Usually this is desirable, i.e. to keep test data as production-like as possible, however if you’d like to disable it, factories also have a withStatus (or similarly named opt) that will not trigger addTransitionReaction reactions on creation:
newBookAdvance(em, { status: AdvanceStatus.Paid })means “created as Paid, and react to that”.addTransitionReactionreactions fire, as they would in production.newBookAdvance(em, { withStatus: AdvanceStatus.Paid })means “created as Paid, and don’t ask why”. Joist doesn’t record a creation transition, so noaddTransitionReactionreactions fire.

